문제 : 에러 메시지가 코드에 직접 작성됨

  • 다양한 경우의 수의 에러 메시지가 비지니스 코드 내에서 직접 작성되어있기 때문에, 어떤 에러 메시지가 있는지 한눈에 알아보기 힘듦
 @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {
        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }
 public SigninResponse signin(SigninRequest signinRequest) {
        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
                () -> new InvalidRequestException("가입되지 않은 유저입니다."));

        // 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
            throw new AuthException("잘못된 비밀번호입니다.");
        }

 

  • 동일한 내용의 에러 메시지가 코드 내에서 여러번 재사용되고 있음
  • 만약 같은 내용이지만 여러 곳에 분포되어 있는 에러 메시지가 추후에 변경된다면 직접 그곳을 찾아다니며 수정해줘야 함
    @Transactional
    public CommentSaveResponse saveComment(AuthUser authUser, long todoId, CommentSaveRequest commentSaveRequest) {
        User user = User.fromAuthUser(authUser);
        Todo todo = todoRepository.findById(todoId).orElseThrow(() ->
                new InvalidRequestException("Todo not found"));
public TodoResponse getTodo(long todoId) {
        Todo todo = todoRepository.findByIdWithUser(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));
@Transactional(readOnly = true)
    public List<ManagerResponse> getManagers(long todoId) {
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

 

에러 메시지를 Enum Class에 담아서 관리하여 개선

에러 객체 분리에 대한 고민

  • ServerException을 좀더 구체적으로 나눌까(관리에러, 날씨 api에러 등..) 고민
    • 구체적으로 나눈다면 추후에 기능이 추가될 때마다 예외 객체들 또한 무수히 많아질 것 같고, 에러를 발생시킨 책임의 주체만 분명히 하고(서버인지, 클라이언트인지) 어차피 구체적인 내용은 에러 메시지를 통하여 전달하면 된다고 생각함
    • 기존에 AuthException(인증, 인가 에러), ServerException(서버에서 발생하는 에러), InvalidRequestException(클라이언트가 잘못된 요청으로 인해 발생하는 에러)를 유지함

 

Enum Class 생성

  • common 패키지 하위에 errorcode를 만들고 에러 메시지를 한번에 관리할 수 있는 ErrorCode  Enum Class를 생성
  • 각각의 Exception Class별로 구분하여 에러 메시지를 배치
public enum ErrorCode {
    // AuthException
    EMAIL_ALREADY_EXISTS("이미 사용 중인 이메일입니다."),
    USER_NOT_FOUND_BY_EMAIL("해당 이메일로 등록된 유저가 없습니다."),
    INVALID_PASSWORD("비밀번호가 잘못되었습니다."),
    AUTH_AND_AUTHUSER_REQUIRED("@Auth와 AuthUser 타입은 함께 사용되어야 합니다."),

    // ServerException
    WEATHER_DATA_FETCH_FAILED("날씨 데이터를 가져오는데 실패했습니다."),
    NO_WEATHER_DATA_FOUND("날씨 데이터가 없습니다."),
    NO_WEATHER_DATA_FOR_TODAY("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다."),
    TOKEN_NOT_FOUND("토큰을 찾을 수 없습니다."),

    // InvalidRequestException
    TODO_NOT_FOUND("해당하는 할일이 없습니다."),
    MANAGER_NOT_FOUND("관리자가 없습니다"),
    NOT_ASSIGNED_TO_SCHEDULE("해당 일정에 등록된 담당자가 아닙니다."),
    CANNOT_REGISTER_AS_SELF("일정 작성자는 본인을 담당자로 등록할 수 없습니다."),
    USER_NOT_FOUND("유저가 존재하지 않습니다."),
    INVALID_SCHEDULE_CREATOR("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다."),
    INVALID_USER_ROLE("유효하지 않은 UserRole입니다."),
    USING_PASSWORD("이미 사용 중인 비밀번호 입니다.");


    private String message;
    private ErrorCode(String message){
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

 

ExceptionClass 수정

기존

public class InvalidRequestException extends RuntimeException {
    public InvalidRequestException(String message) {
        super(message);
    }
}

 

수정 후

public class InvalidRequestException extends RuntimeException {
    private ErrorCode errorCode;
    public InvalidRequestException(ErrorCode errorCode) {
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode(){
        return this.errorCode;
    }
}

 

 

GlobalExceptionHandler 수정

기존

@ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> invalidRequestExceptionException(InvalidRequestException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getMessage());
    }

 

수정 후

@ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> invalidRequestExceptionException(InvalidRequestException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getErrorCode().getMessage());
    }

 

 

개선점

  • enum을 사용하여 에러 메시지를 한곳에 모아뒀기 때문에, 한눈에 에러 메시지를 확인할 수 있음
  • 추후에 에러 메시지를 유지보수하기 용이해짐 (동일한 에러 메시지를 코드에서 직접 찾아서 변경할 필요 없이 enum 한 곳에서만 변경하면 됨)
  • 중복된 에러 메시지를 일일히 타이핑할일 없이 enum에 있는 메시지를 쓰면 되기 때문에 재사용성이 좋아짐 
  • 비지니스 로직에서 해당 에러 메시지를 봤을 때, 가독성 측면에서 향상됨

 

데이터 비교

개선 전 - 동일한 메시지가 여러 곳에서 사용되어 있고, 직접 서비스 코드 내에서 에러 메시지가 작성되어있어 유지보수에 용이하지 않음

 

개선 후 - Enum Class를 사용하여 에러 메시지를 한곳에서 관리하기 용이해짐

 

HttpMessageConverter

@RequestBody, @ResponseBody 을 사용할때 사용됨 → 요청이나 응답 모두에 사용된다는 의미

  • 요청시에는 Argument Resolver가 사용
  • 응답시에는 ReturnValueHandler가 사용
  • JSON을 객체로 변환하는데는 Jackson 라이브러리 사용 (@JsonFormat, Deserializer)

 

HttpMessageConverter 우선순위

  1. ByteArrayHttpMessageConverter : byte[] Data를 처리
  2. StringHttpMessageConverter : String Data를 처리
  3. MappingJackson2HttpMessageConverter : JSON Data를 처리

 

HttpMessageConverter 동작 순서

읽기

  1. 클라이언트가 HTTP 요청을 보냄
  2. Spring MVC가 컨트롤러의 @RequestBody를 확인하고 MessageConverter를 실행
  3. MessageConverter는 canRead() 메서드로 읽기가능 여부를 조회
    • 요청 데이터 타입이 지원 가능한지 (ex: application/json )
    • 변환할 객체 타입이 적절한지 (ex: RequestDto를 만들 수 있는지)

4. read() 메서드를 호출하여 객체 생성 : JSON 데이터를 RequestDto 객체로 변환

 

쓰기

  1. 컨트롤러 메서드에서 객체 반환 : return responseDto
  2. @ResponseBody가 붙어 있으므로 스프링에서 HttpMessageConverter가 실행
  3. canWrite()를 호출하여 데이터 변환 가능 여부 확인
    • 반환 클래스가 byte[], String, Object 인지 여부 확인
    • 요청 헤더 Accept 의 Media Type 지원여부 확인(produces)
  4. write() 메서드를 호출 : HTTP Response Message Body에 데이터를 입력

 

Spring MVC 구조

  • HandlerMappingList를 조회하여 적절한 컨트롤러 찾기
    • 여러 개의 HandlerMapping을 순차적으로 조회하면서 요청을 처리할 컨트롤러(Handler)를 찾음
    • ex) RequestMappingHandlerMapping : @RequestMapping으로 해당 핸들러를 찾음
    • GET /users/1 요청이 오면 RequestMappingHandlerMapping **@RequestMapping("/users/{id}")가 있는 컨트롤러를 찾아 반환
  • HandlerAdapter는 찾은 컨트롤러를 실행할 수 있도록 변환해 주는 역할
    • 찾은 컨트롤러의 실행을 담당
  • 찾은 컨트롤러의 메서드를 실행하고 결과를 반환
    • @RequestMapping("/users/{id}")가 있는 컨트롤러 메서드 실행

 

RequestMappingHandlerAdapter

  • @RequestMapping (@PostMapping , @GetMapping)을 처리하는 HandlerAdapter의 구현체

 

ArgumentResolver

  • 요청이 컨트롤러 메서드에 전달될 때 각 파라미터를 적절한 객체로 변환하여 주입하는 것을 담당
  • RequestMappingHandlerAdapter는 ArgumentResolver를 호출하여 Controller가 필요한 다양한 파라미터의 값을 생성
  • HttpServletRequest, Model, HttpEntity,@ModelAttribute, @RequestBody, @RequestParam 등 다양한 파라미터 바인딩을 할 수 있는 이유
  • HandlerMethodArgumentResolver
    • ArgumentResolver의 실제 이름, 인터페이스로 구성됐기에 mplements 하여 확장 가능

 

ReturnValueHandler

  • ModelAndView, @ResponseBody, HttpEntity<> 등이 있으면 응답에 필요한 값으로 변환
  • Controller에서 String으로 ViewName을 반환하여도 View가 동작하는 이유
  • HandlerMethodReturnValueHandler
    • ReturnValueHandler의 실제 이름, 인터페이스로 구성됐기에 mplements 하여 확장 가능

친구 관리 기능 관련 ERD

  • 역방향 친구 신청 등록을 방지하기 위해서 친구 요청을 보내게 되면 정방향으로 1번 역방향으로 1번 쿼리가 insert됨
    • 1. 정방향 데이터 추가 : A가 B한테 친구신청을 보내서, from_id : a, to_id : b, is_friend : True를 insert
    • 2. 역방향 데이터 추가 : from_id : b, to_id : a, is_friend : False를 insert

 

문제 : 의도하지 않은 inner join 수행 결과값

  • 친구 신청한 목록을 조회하기 위해서 friend 테이블에 inner join을 수행했는데, 중복컬럼이 반복해서 나오게 됨

1번은 지금 2번과 3번에게 친구 신청을 보낸 상태

 

inner join을 사용하여 정방향 역방향을 합쳐서 데이터 조회를 수행함

select *
from friend f
inner join friend r
on f.from_id = r.to_id

 

하지만 조회 결과를 보니 중복된 컬럼이 나옴!!!

 

하지만 아무리 생각해도 f.from_id = r.to_id 이 조건이 전혀 틀린 것 같다는 생각이 들지 않았음

근데 더 고민을 해보니, 결국 inner join 이라 함은 자기 자신을 자기 자신과 조인하는 거기 때문에 역방향 기준도 생각을 해서 조인을 시켜줘햐 한다는 것을 깨달았다!

 

r.from_id = f.to_id 조건 추가!

select *
from friend f
inner join friend r
on f.from_id = r.to_id and r.from_id = f.to_id

 

중복없이 제대로 된 결과를 조회 할 수 있었다!

 

결론 : join 조건을 걸 때 inner join의 경우 한번 더 생각해서 조건을 걸어줘야 한다! 결국 friend 테이블에서는 from_id와 to_id는 같은 역할의 컬럼이기 때문에 당연히 동등하게 조건을 걸어 줘야 하는 것이다!!

 

문제 : 변경되지 않은 코드에서 갑자기 쿼리 수행이 제대로 되지 않음

친구 관련 기능을 모두 구현하고 이제 dev 브랜치에서 pull 을 받고 마저 테스트를 하는 중이었다..

그런데...!!!! 친구 관련 쪽 코드는 전혀 건들지 않았는데 갑자기 친구 수락 기능이 제대로 작동하지 않는 것이었다 ㅜㅜ

아무리 생각해도 변경된 코드도 없고 안될 이유가 없는데 너무 당황한 나머지 진짜 멘붕이 왔다...

 

첫번째 시도 : 스키마 자체를 날리고 다시 만들기

-> 스키마를 아예 삭제해버리고 application.properties의 속성도 변경해주고 친구 수락을 수행했다

spring.jpa.hibernate.ddl-auto=create

 

그러나 여전히 똑같은 실패 😵

 

두번째 시도 : 로그 찍기

아무래도 이쯤되니 쿼리가 문제인가 싶어서 로그를 찍어서 실제 값이 어떻게 넘어오는지 확인해보았다...

 

여기서는 friendReceivedtList 이게 분명히 조회가 잘 되는데!!!

그 결과 여기서는 리스트 안에 값이 정상적으로 담겨오고

여기서는 friendReceivedtList의 값이 빈값으로 나온다!!!!ㅜㅜㅜㅜㅜ

 

둘이 아예 똑같은 쿼리인데도 위에서는 잘 조회되고, 아래에서는 조회가 안되는 것이다....

기한이 하루 남았는데 갑자기 잘 되던 기능이 안되니까 정말 멘붕이 왔다!!!

 

세번째 시도 : 캐시 지우기

이쯤되니 정말 자포자기 하는 심정으로 구글링과 gpt한테 하소연을 했다...

이때 쿼리가 제대로 적용되지 않는 이유가 캐시 때문일 수도 있으니 캐시를 지우고 시도해보라는 답을 보게 되었다!!

@Service
public class FriendService {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void accept(FriendRequestAcceptDto acceptRequestDto, Long memberId) {
        // 세션을 clear하여 캐시를 초기화
        entityManager.clear();

        // 이후 코드
        List<Long> friendReceivedtList = friendRepository.getFriendReceivedList(memberId);
    }
}

 

엔티티 매니저를 가지고 와서 캐시를 초기화해 줬더니 해결되었다!!!

 

Hibernate 1차 캐시

  • Hibernate는 성능 최적화를 위해 1차 캐시 사용
  • 세션 레벨에서 관리되는 캐시로, 같은 세션 내에서 동일한 엔티티가 여러 번 조회되면 DB에서 쿼리를 실행하지 않고 캐시된 데이터를 반환
  • clear()를 호출하기 전에는, 데이터베이스에서 데이터를 가져와도 세션에 이미 캐시된 값이 존재하면 그것을 사용함
  • 이로 인해 최근 변경된 데이터새로 조회된 데이터가 반영되지 않아서 조회가 제대로 되지 않았을 수 있음

 

캐시의 존재를 알고 있었지만 이렇게 내가 개발하면서 몸소 느끼게 될 줄은 몰랐다..😂

다음에 또 이런 오류가 생길 때 고생했던 만큼 금방 해결할 수 있을거라고 생각한다

 

결론 : 쿼리가 결과가 이상하게 나온다면 캐싱을 의심해 봐아 한다!

 

@RequestBody

  • HTTP 요청의 본문(payload) 데이터를 Java 객체로 매핑해줌
  • JSON 데이터를 DTO 객체로 변환

 

위와 같이 요청을 JSON 데이터를 요청 보내면 아래의 객체로 매핑됨!

@PostMapping("/signup")
    public ResponseEntity<SignupResponseDto> signUp(@Valid @RequestBody SignupRequestDto requestDto){
        SignupResponseDto signupResponseDto = authService.signUp(requestDto);
        return new ResponseEntity<>(signupResponseDto, HttpStatus.CREATED);
    }

 

동작 원리

1.  HttpMessageConverter

  • Spring이 HttpMessageConverter를 사용하여 HTTP의 Body를 읽어옴!

2.  Jackson은 객체를 생성하기 위해 기본 생성자를 사용

  • Jackson : JSON 데이터를 Java 객체로 변환하는 라이브러리
  • HttpMessageConverter가 Jackson 라이브러리를 사용하여 JSON 데이터를 Java 객체로 변환

3. 기본생성자로 Dto 객체를 생성한 후에 필드값들은 NULL로 정의되어 있음

4. 리플렉션을 통해서 객체 값 세팅

  • Dto의 내부 필드는 private이지만 리플렉션(reflection)을 통해 객체.필드값 = Http Body와 매칭할 값 으로 필드값을 세팅

 

리플렉션(Reflection)

  • Java의 클래스, 메서드, 필드, 생성자 등의 정보 실행 중(runtime)에 동적으로 탐색하고 조작할 수 있는 기능
  • 캡슐화(private, protected) 필드나 메서드에도 접근이 가능!

 

-> 그러므로 RequestDto는 기본생성자를 사용하고, 필드에는 final을 붙일 필요가 없음!

 

이렇게 작성하면 된다!

@Getter
public class SignupRequestDto {
    private String name;
    private String email;
    private String password;

}

 

하지만 필드를 final로 선언하고 생성자를 따로 만들어도 작동은 됐었다! -> 기본 생성자가 없는 경우 Jackson은 클래스의 파라미터가 있는 생성자를 사용해 객체를 생성 하기 때문이다.

 

결론 : 그래도 앞으로 요청 Dto를 만들 때는 기본생성자 + final 안쓰기 조합으로 만들자!

 

 

 

생성 시간 및 수정시간 관리 : JPA Auditing

  • JPA Auditing : 엔티티의 생성, 수정 시간을 자동으로 기록하고 관리해주는 기능
  • @CreatedDate : 엔티티가 처음 저장될 때 생성 시간 저장
  • @LastModifiedDate : 엔티티가 업데이트될 때 수정 시간 저장 

설정방법

  1. @EnableJpaAuditing를 활성화
  2. @EntityListeners(AuditingEntityListener.class)를 엔티티에 추가
  3. 추상 클래스 필드에 @CreatedDate, @LastModifiedDate 등의 어노테이션 사용
  4. 나머지 객체에서 추상클래스를 extends해서 사용
@EnableJpaAuditing // Auditing 기능(entity 생성, 수정 시간 자동으로 기록) 활성화
@SpringBootApplication
public class ScheduleDevelopApplication {
}
@Getter
@MappedSuperclass // 해당 클래스를 구현했을 때 데이터베이스에 잘 적용되도록 해줌
@EntityListeners(AuditingEntityListener.class) // Auditing 리스너 등록, 생성되거나 수정될 때 값을 업데이트 함
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;
}

 

Comment Entity

  • 생성 및 수정 시간을 기록하는 클래스인 BaseEntity를 상속 
  • Schedule클래스와 Member클래스를 단방향 연관관계로 알고 있음 (어떤 게시물의 댓글인지, 누가 썼는지 알아야 하므로!)
@Getter
@Entity
@Table(name = "comment")
public class Comment extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String content;

    @ManyToOne()
    @JoinColumn(name = "schedule_id")
    private Schedule schedule;

    @ManyToOne()
    @JoinColumn(name = "member_id")
    private Member member;

    @Builder
    public Comment(String content) {
        this.content = content;
    }

    public Comment() {
    }

    public void setSchedule(Schedule schedule){
        this.schedule = schedule;
    }

    public void setMember(Member member){
        this.member = member;
    }

    public void updateComment(String content){
        this.content = content;
    }
}

 

댓글 생성

  • CommentSaveRequestDto : scheduleId, memberId, comment
  • Dto에서 받아온 값으로 Member, Schedule객체를 조회하고 Comment객체를 생성해서 set해준 후 저장함
  • 모든 작업 단위는 @Transactional을 붙여줘야 확실하게 실행 단위 관리가 되기 때문에 붙여줌 (조회는 (readOnly = true))
@Transactional
    public CommentResponseDto save(CommentSaveRequestDto requestDto) {
        Member member = memberRepository.findById(requestDto.getMemberId()).orElseThrow(() -> new MemberNotFoundException());
        Schedule schedule = scheduleRepository.findById(requestDto.getScheduleId()).orElseThrow(() -> new ScheduleNotFoundException());

        Comment comment = Comment.builder()
                                .content(requestDto.getComment())
                                .build();

        comment.setMember(member);
        comment.setSchedule(schedule);

        schedule.addComment(comment);

        Comment savedComment = commentRepository.save(comment);

        return CommentResponseDto.buildDto(savedComment);
    }

 

댓글 조회

  • 댓글 id 값을 통해서 Comment객체 조회
  • 조회이므로 @Transactional(readOnly = true)를 붙여주면 성능이 향상!
@Transactional(readOnly = true)
    public CommentResponseDto findById(Long id) {
        Comment comment = commentRepository.findById(id).orElseThrow(() -> new CommentNotFoundException());
        return CommentResponseDto.buildDto(comment);
    }

 

댓글 수정

  • CommentUpdateRequestDto : comment (댓글 내용)
  • Comment 엔티티 내부에 있는 updateComment 메서드를 사용하여 set 해줌
  • @Transactional을 무조건 명시해 줘야 함! -> JPA의 변경 감지가 작동하는 범위, 명시해주지 않는다면 따로 save()를 해줘야 함!
 @Transactional
    public CommentResponseDto update(Long id, CommentUpdateRequestDto requestDto) {
        Comment comment =  commentRepository.findById(id).orElseThrow(() -> new CommentNotFoundException());
        comment.updateComment(requestDto.getComment());
        return CommentResponseDto.buildDto(comment);
    }

 

댓글 삭제

@Transactional
    public void delete(Long id) {
        Comment comment =  commentRepository.findById(id).orElseThrow(() -> new CommentNotFoundException());
        commentRepository.delete(comment);
    }

 

일정 페이징 조회

ScheduleEntity

  • 조회할 데이터 중에서 "댓글의 개수"가 있었기 때문에 Comment와 Schedule을 양방향 연관관계로 설정
  • addComment 메서드를 Comment를 추가
@Getter
@Entity
@Table(name = "schedule")
public class Schedule extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String content;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "schedule", fetch = FetchType.LAZY)
    private List<Comment> comments = new ArrayList<>();

    @Builder
    public Schedule(String title, String content) {
        this.title = title;
        this.content = content;
    }

    public Schedule() {
    }

    public void setMember(Member member) {
        this.member = member;
    }

    public void updateSchedule(String title, String content){
        this.title = title;
        this.content = content;
    }

    public void addComment(Comment comment){
        this.comments.add(comment);
    }
}

 

구현

  • page, size를 쿼리 파라미터로 받음
@GetMapping()
    public ResponseEntity<Page<SchedulePageResponseDto>> findAll(@RequestParam(value = "page", defaultValue = "0") int page,
                                                                 @RequestParam(value = "size", defaultValue = "10") int size){
        Page<SchedulePageResponseDto> scheduleResponseDto = scheduleService.findAll(page, size);
        return new ResponseEntity<>(scheduleResponseDto, HttpStatus.OK);
    }

 

  • repository에 Page타입으로 리턴받는 조회 메서드 추가
@Repository
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
    Page<Schedule> findAll(Pageable pageable);
}

 

  • PageRequest 를 통해서 page, size를 입력
  • Page<Schedule>로 받아온 객체를 map함수를 통해서 Page<SchedulePageResponseDto>로 변경
  • 회원명과 댓글개수는 schedule엔티티 내부에있는 각각의 엔티티에서 조회해 옴
@Transactional(readOnly = true)
    public Page<SchedulePageResponseDto> findAll(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        Page<Schedule> schedulePage = scheduleRepository.findAll(pageable);
        return schedulePage.map(schedule -> {
            Member member = schedule.getMember();
            Long commentCount = (long) schedule.getComments().size();
            return SchedulePageResponseDto.builder()
                    .title(schedule.getTitle())
                    .content(schedule.getContent())
                    .author(member.getName())
                    .commentCount(commentCount)
                    .createdAt(schedule.getCreatedAt())
                    .modifiedAt(schedule.getModifiedAt())
                    .build();
        });
    }

 

  • Application에 @EnableSpringDataWebSupport를 추가하면 Page객체에서 핵심 내용만 조회할 수 있음
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO) // Page 객체에서 필요한 부분만 조회할 수 있음
@SpringBootApplication
public class ScheduleDevelopApplication {

    public static void main(String[] args) {
        SpringApplication.run(ScheduleDevelopApplication.class, args);
    }

 

결과

요청 : http://localhost:8080/schedules?page=1&size=3

예외처리 및 검증

에러 응답 객체

@Getter
public class CommonErrorResponse {

    private String status;
    private String message;
    private int code;


    public CommonErrorResponse(HttpStatus status, String message){
        this.status = status.name();
        this.code = status.value();
        this.message = message;
    }

}

 

예외 처리 클래스

  • RuntimeException을 상속한 ApplicationException을 생성
@Getter
public class ApplicationException extends RuntimeException {
    private final HttpStatus status;

    public ApplicationException(String message, HttpStatus status) {
        super(message);
        this.status = status;
    }
}
  • 나머지 커스텀 예외 처리 클래스는 모두 ApplicationException를 상속
public class InvalidPasswordException extends ApplicationException{
    public InvalidPasswordException() {
        super("비밀번호가 일치하지 않습니다.", HttpStatus.UNAUTHORIZED);
    }
}

 

예외처리 클래스들

 

ExceptionHandler

총 3가지 경우의 ExceptionHandler를 만듦

 

런타임 에러 발생 핸들러 (ApplicationException.class)

  • RuntimeException을 상속한 ApplicationException을 상속한 예외가 발생했을 때 처리해주는 핸들러
  • CommentNotFoundException(존재하지 않는 댓글), InvalidPasswordException(비밀번호가 틀린 경우) 등등의 예외처리
@ExceptionHandler(ApplicationException.class)
    public ResponseEntity<CommonErrorResponse> handleApplicationException(ApplicationException e){
        CommonErrorResponse errorResponse = new CommonErrorResponse(e.getStatus(), e.getMessage());
        log.error("[ApplicationExceptionHandler] ApplicationException = {}, class = {}", e.getMessage(), e.getClass());
        return new ResponseEntity<>(errorResponse, e.getStatus());
    }

 

Dto 검증이 잘못된 경우에 발생하는 에러를 관리하는 핸들러 (handleValidationException)

  • 요청값이 객체로 반환된 후에 @Valid를 통한 검증에 걸리는 경우의 에러를 처리하는 핸들러
  • @NotBlank, @Email 등을 안 지킨 경우
@ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<CommonErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
        String firstErrorMessage = null;
        for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
            firstErrorMessage = fieldError.getDefaultMessage();
            break;
        }

 

바인딩 실패한 경우 발생하는 에러를 관리하는 핸들러 (handleHttpMessageNotReadableException)

  • @RequestBody는 HTTP Body Data를 Object로 변환하므로 변환이 실패된다면 Controller를 호출하지 않음
  • 객체 바인딩이 실패한 경우 (Long타입에 String을 넣어서 전송한 경우)
@ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<CommonErrorResponse> handleHttpMessageNotReadableException(HttpMessageNotReadableException e){
        CommonErrorResponse errorResponse = new CommonErrorResponse(HttpStatus.BAD_REQUEST, "객체 바인딩이 실패하였습니다. 타입을 다시 확인해주세요.");
        log.error("[HttpMessageNotReadableExceptionHandler] HttpMessageNotReadableException = {}, class = {}", e.getMessage(), e.getClass());
        return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
    }

 

구현하면서 알게 된 점

아래 두개 때문에 시간을 좀 잡아먹었다 🙄

  • @NotBlank : String에만 사용해야 함, Long타입에는 @NotNull을 사용해야 함!
  • @Size : 문자열 길이에 사용하는 것, @Range는 숫자에 사용하는 것!

 

비밀번호 암호화

  • BCrypt 라이브러리 설치 후 스프링 빈에 등록
  • BCrypt.MIN_COST : 암호화를 할 때 사용하는 비용, 적을수록 성능이 높아지지만 암호화의 수준은 낮아짐
@Component
public class PasswordEncoder {

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

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

 

  • encode : 암호화를 수행하는 함수
  • 암호화 수행 후에 데이터베이스에 비밀번호 저장
@Transactional
    public SignupResponseDto signUp(SignupRequestDto requestDto) {
        String encodedPassword = passwordEncoder.encode(requestDto.getPassword());

        Member member = Member.builder()
                        .name(requestDto.getName())
                        .email(requestDto.getEmail())
                        .password(encodedPassword)
                        .build();

        Member savedMember = memberRepository.save(member);

        return SignupResponseDto.buildDto(savedMember);
    }

 

  • matches : 암호화하기 전 값과 암호화한 후의 값을 비교하는 함수
  • 사용자가 입력한 비밀번호와 데이터베이스에 암호화된 비밀번호를 비교
if (passwordEncoder.matches(rawPassword, member.getPassword())){
            return new LoginResponseDto(member.getId());
        }

 

 

댓글 연관관계를 추가한 ERD

고민했던 점

  • 처음에 ERD를 설계할 때 댓글에 회원 FK가 들어가서 테이블이 사이클 형태가 만들어져서 잘 설계한 것인지 고민됨
  • 일정 테이블에도 FK로 회원ID를 가지고 있는데 굳이 댓글 테이블에도 회원 FK를 넣어야 하나 고민했음
  • 하지만 생각해보니 일정은 "일정 작성자 식별자"만 가지고 있고, 댓글은 "댓글 작성자의 식별자" 를 가지고 있는 것이기 때문에 추가하는 것이 맞았음 -> 짱구가 쓴 일정에 짱구아빠 흰둥이가 댓글을 달 수 있기 때문에 엄연히 다르다!  

 

SQL

CREATE TABLE member
(
    id         BIGINT AUTO_INCREMENT PRIMARY KEY NOT NULL COMMENT '회원 식별자',
    name       VARCHAR(200) NOT NULL COMMENT '이름',
    email      VARCHAR(50) NOT NULL COMMENT '이메일',
    password   VARCHAR(50) NOT NULL COMMENT '비밀번호',
    created_at TIMESTAMP NOT NULL COMMENT '작성일',
    updated_at TIMESTAMP NOT NULL COMMENT '수정일'
);

CREATE TABLE schedule
(
    id         BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '일정 식별자',
    member_id  BIGINT COMMENT '작성자 식별자',
    title      VARCHAR(200) NOT NULL COMMENT '제목',
    content    TEXT NOT NULL COMMENT '일정 내용',
    created_at TIMESTAMP NOT NULL COMMENT '작성일',
    updated_at TIMESTAMP NOT NULL COMMENT '수정일',
    FOREIGN KEY (member_id) REFERENCES member(id)
);

CREATE TABLE comment
(
    id         BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 식별자',
    member_id  BIGINT COMMENT '작성자 식별자',
    schedule_id  BIGINT COMMENT '일정 식별자',
    content    TEXT NOT NULL COMMENT '댓글 내용',
    created_at TIMESTAMP NOT NULL COMMENT '작성일',
    updated_at TIMESTAMP NOT NULL COMMENT '수정일',
    FOREIGN KEY (member_id) REFERENCES member(id),
    FOREIGN KEY (schedule_id) REFERENCES schedule(id)
);

 

API

 

DTO

  • 로그인, 일정, 회원, 댓글 부분으로 총 4개의 역할에 따라 분리

 

세션 로그인 구현

1. 로그인 로직 실행

  • LoginRequestDto : 사용자로부터 이메일과 비밀번호를 입력받음
  • 사용자로부터 입력받은 비밀번호가 데이터베이스에 저장된 암호화된 비밀번호랑 동일한 경우, LoginResponseDto에 사용자 id값을 넣어서 반환
public LoginResponseDto login(LoginRequestDto requestDto){
        String rawPassword = requestDto.getPassword();
        Member member = memberRepository.findByEmail(requestDto.getEmail()).orElseThrow(() -> new MemberNotFoundException());
        if (passwordEncoder.matches(rawPassword, member.getPassword())){
            return new LoginResponseDto(member.getId());
        } else {
            throw new InvalidPasswordException();
        }
    }

 

2. HttpServletRequest를 받아서 session에 sessionKey 입력해 주기

  • HttpServletRequest : 클라이언트가 보낸 HTTP 요청 정보(헤더, 파라미터, 세션 정보 등)를 담고 있는 객체
  • 로그인이 성공했다면 클라이언트의 세션에 ( "loginUser": "사용자pk값") 형식으로 sessionKey 추가해서 응답
  • 키 값은 상수로 관리 (반드시 인터페이스나 추상클래스로 관리)
  • 세션ID는 쿠키를 통해서만 전송되도록 application.properties에 아래와 같이 설정 (설정하지 않는다면 최초 로그인시에 세션 ID값이 URL로 나옴)
server.servlet.session.tracking-modes=cookie
@PostMapping("/login")
    public ResponseEntity<LoginResponseDto> login(@Valid @RequestBody LoginRequestDto loginRequestDto, HttpServletRequest servletRequest){
        LoginResponseDto responseDto = authService.login(loginRequestDto);
        HttpSession session = servletRequest.getSession();
        session.setAttribute(Const.LOGIN_USER, responseDto);
        return new ResponseEntity<>(responseDto, HttpStatus.OK);
    }

 

  • 로그인이 성공한 경우에 세션ID값이 쿠키를 통해서 전송됨

 

3. Filter 생성하기

고민했던 점

  • 세션 활성화 여부를 확인할 때 조건에서 session.getId()를 사용했더니 로그인 검증이 제대로 되지 않았음
    • 세션 ID는 세션이 활성화 됐을 때만 존재하는 아이디인줄 알았음
    • 알고보니 HttpSession 객체가 생성되면 서버에서 세션 ID를 부여하고 이 값은 세션이 활성화되지 않거나 필요한 속성이 저장되지 않아도 반환되는 값이라고 함
    • 세션 ID가 존재한다고 해서 세션에 유효한 데이터가 있다는 보장 x
  • session.getAttribute("sessionKey")의 존재여부로 검증해서 해결 
    • 사용자가 로그인이 성공했다면 로그인시에 세션에 ( "loginUser": "사용자pk값") 형식으로 sessionKey 추가해서 응답했기 때문에 이 값이 존재하는지 확인하는 것으로 로그인을 했는지 확인 가능
    • 세션이 만료되거나 로그아웃 되면 해당 값은 존재하지 않음
    • sessionKey가 존재한다면 세션에 유효한 데이터가 있다는 보장 o
 if (!isWhiteList(requestURI)){
            // 세션이 존재하지 않는다면 생성하지 않음 (= 로그인을 하지 않았다는 의미)
            HttpSession session = httpServletRequest.getSession(false);
            if (session == null || session.getAttribute("loginUser") == null) {
                throw new UnauthorizedException();
            }
        }

 

코드 설명

  • WHITE_LIST : 필터를 거치지 않아도 되는 인가가 필요없는 경로 리스트
  • ServletRequest는 기능이 적기 때문에 HttpServletRequest로 다운캐스팅
  • chain.doFilter(request,response) 이후에 다음 필터가 있다면 그 필터로 가고, 없다면 바로 Servlet 호출
@Slf4j
public class LoginFilter implements Filter {
    public static final String[] WHITE_LIST = {"/auth/signup", "/auth/login", "/auth/session"};

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // ServletRequest는 기능이 적기 때문에 HttpServletRequest로 다운캐스팅
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        String requestURI = httpServletRequest.getRequestURI();

        HttpServletResponse httpServletResponse = (HttpServletResponse) response;

        log.info("로그인 필터 실행");

        // 검증을 제회하는 URI에 포함되지 않은 경우 검증 실행
        if (!isWhiteList(requestURI)){
            // 세션이 존재하지 않는다면 생성하지 않음 (= 로그인을 하지 않았다는 의미)
            HttpSession session = httpServletRequest.getSession(false);
            if (session == null || session.getAttribute("loginUser") == null) {
                throw new UnauthorizedException();
            }
        }

        // 더 이상 호출할 필터가 없으면 Servlet 바로 호출
        chain.doFilter(request,response);
    }

    private boolean isWhiteList(String requestURI) {
        return PatternMatchUtils.simpleMatch(WHITE_LIST, requestURI);
    }
}

 

4. Filter 적용하기

  • FilterRegistrationBean : 스프링 부트에서 필터를 등록하고 설정할 때 사용하는 클래스
  • 우선순위는 숫자가 적을수록 높음
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Bean
    public FilterRegistrationBean loginFilter() {
        FilterRegistrationBean<Filter> filterFilterRegistrationBean = new FilterRegistrationBean<>();
        filterFilterRegistrationBean.setFilter(new LoginFilter());
        filterFilterRegistrationBean.setOrder(1);
        filterFilterRegistrationBean.addUrlPatterns("/*");

        return filterFilterRegistrationBean;
    }
}

 


추가 +) 해설 세션을 듣고 추가한 내용 ^^;;

생각해보니 memberId값을 로그인을 수행한 후에 세션 속성에 넣었기 떄문에, 요청을 보낼 때 세션에 있는 해당 memberId 속성값을 사용해서 사용해야 하는데, 이걸 빼먹어 버렸다 🙀 (세션의 역할이 사용자 식별 이건데 ...ㅎㅎㅎㅎ)

 

세션 로그인을 통한 인가

  • 로그인 성공시에 세션에 지정해 줬던 값
session.setAttribute(Const.LOGIN_USER, responseDto.getId());
  • 세션에 저장된 loginUser값을 꺼내서 어떤 회원의 요청인지 확인할 때 사용!
  • @SessionAttribute : 세션에 저장된 데이터를 컨트롤러에서 사용할 수 있게 하는 역할
  • 이제 요청 Dto에는 userId가 없음!
@PatchMapping()
public ResponseEntity<ScheduleResponseDto> update(@RequestBody @Valid ScheduleUpdateRequestDto requestDto,
                                                  @SessionAttribute(name = Const.LOGIN_USER) Long userId){
    ScheduleResponseDto scheduleResponseDto = scheduleService.update(userId, requestDto);
    return new ResponseEntity<>(scheduleResponseDto, HttpStatus.OK);
}

API 명세서

  • 수정이나 삭제는 로그인이 성공한 후에 진행한다고 가정하고 만들었기 때문에 따로 요청 Dto에 비밀번호를 넣지 않음

 

Dto 목록

  • 생성, 수정 등 역할에 따라 Dto를 분리함
  • 크게 일정, 회원, 로그인 파트 3가지로 나눔

 

ERD

  • 회원은 여러개의 일정을 작성할 수 있기 때문에 회원 : 일정 = 1 : N

 

테이블 생성 SQL

CREATE TABLE member
(
    id         BIGINT AUTO_INCREMENT PRIMARY KEY NOT NULL COMMENT '회원 식별자',
    name       VARCHAR(200) NOT NULL COMMENT '이름',
    email      VARCHAR(50) NOT NULL COMMENT '이메일',
    password   VARCHAR(50) NOT NULL COMMENT '비밀번호',
    created_at TIMESTAMP NOT NULL COMMENT '작성일',
    updated_at TIMESTAMP NOT NULL COMMENT '수정일'
);

CREATE TABLE schedule
(
    id         BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '일정 식별자',
    member_id  BIGINT COMMENT '작성자 식별자',
    title      VARCHAR(200) NOT NULL COMMENT '제목',
    content    TEXT NOT NULL COMMENT '일정 내용',
    created_at TIMESTAMP NOT NULL COMMENT '작성일',
    updated_at TIMESTAMP NOT NULL COMMENT '수정일',
    FOREIGN KEY (member_id) REFERENCES member(id)
);

+ Recent posts