Skip to content

컨벤션

deepredk edited this page Nov 19, 2023 · 4 revisions

코딩 컨벤션

구글 코딩 컨벤션

1 IntelliJ Code Style 설정

구글 코딩컨벤션을 지키지 않으면 노란 밑줄이 생기도록 하고, Reformat(컨트롤+알트+L) 할 때 이 설정으로 작동함

  1. Preferences -> Editor -> Code Style -> Java에 들어간다.
  2. Scheme 우측에 있는 톱니바퀴 -> Import Scheme -> Checkstyle configuration
  3. 프로젝트 최상위 폴더에 있는 custom_google_checks.xml을 연다

2 Checkstyle 설정

  1. Preferences -> Plugins -> Marketplace에 들어간다.
  2. CheckStyle-IDEA를 찾아 설치한다.
  3. Preferences -> Tools -> Checkstyle에 들어간다.
  4. Configuration File 밑의 +를 눌러 custom_google_checks.xml을 추가한다.
  5. 추가한 설정을 아래 이미지처럼 체크한 뒤 OK를 누른다.
image

클래스 맨 윗 줄 줄바꿈

class Class {

    int field;

    void method() {

    }
}
  • 모든 클래스의 맨 윗줄은 줄바꿈, 맨 아랫줄은 줄바꿈하지 않습니다

@Builder 및 protected

@NoArgsConstruct(AccessLevel.PROTECTED)
class Entity {

    Long id;
    int field1;
    int field2;
    int field3;

    @Builder
    public Entity(int field1, int field2, int field3) {

    }
}
  • 필드가 3개 이상인 클래스는 항상 @Builder를 사용하여 객체를 생성합니다. (생성자 X)
  • 생성자를 만들어줘야 할 필요가 있을 때는 protected로 생성합니다. (생성자를 실수로 사용할 일이 없도록)

공통 응답

모든 컨트롤러에서 공통적으로 반환해야 하는 필드를 부모 클래스에 모아 미리 정의해두었음

공통 응답 클래스

@Getter
@RequiredArgsConstructor
public class ResponseBody<T> {

    private final Status status;
    private final String errorMessage;
    private final T data;

    public static ResponseBody<Void> ok() {
        return new ResponseBody<>(Status.SUCCESS, null, null);
    }

    public static <T> ResponseBody<T> ok(T data) {
        return new ResponseBody<>(Status.SUCCESS, null, data);
    }

    public static ResponseBody<Void> fail(String errorMessage) {
        return new ResponseBody<>(Status.FAIL, errorMessage, null);
    }

    public static <T> ResponseBody<T> fail(String errorMessage, T data) {
        return new ResponseBody<>(Status.FAIL, errorMessage, data);
    }

    public static ResponseBody<Void> error(String errorMessage) {
        return new ResponseBody<>(Status.ERROR, errorMessage, null);
    }

    public static <T> ResponseBody<T> error(String errorMessage, T data) {
        return new ResponseBody<>(Status.ERROR, errorMessage, data);
    }
}

공통 응답 클래스 활용 예시

@DeleteMapping("/{id}")
public ResponseBody<Void> deleteComment(@PathVariable Long id) {
    commentService.deleteComment(id);
    return ResponseBody.ok();
}
@PutMapping("/{id}")
public ResponseBody<CommentEditResponse> editComment(
    @Valid @RequestBody CommentEditRequest request
) {
    var response = commentService.editComment(mapper.of(request));
    return ResponseBody.ok(mapper.of(response));
}

예외 처리

BaseException

@Getter
public abstract class BaseException extends RuntimeException {

    public BaseException() {
        super();
    }

    public BaseException(String message) {
        super(message);
    }

    public BaseException(String message, Throwable cause) {
        super(message, cause);
    }
}
  • 처리할 모든 예외를 이 BaseException을 상속하여 만들면 됨

예외 클래스 예시

public class TokenExpireException extends BaseException {

    public TokenExpireException() {
        super(ErrorCode.TOKEN_EXPIRED.getErrorMsg());
    }
}

// ErrorCode 클래스
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    TOKEN_EXPIRED("만료된 토큰입니다.");

    private final String errorMsg;

    public String getErrorMsg(Object... arg) {
        return String.format(errorMsg, arg);
    }
}

예외 처리 코드

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = BaseException.class)
    public ResponseBody<Void> handleBaseException(BaseException e) {
        log.warn("[BaseException] Message = {}", e.getMessage());
        return ResponseBody.fail(e.getMessage());
    }
}

예외 처리 사용 예시

image

Controller DTO

패키지 구조 및 클래스 네이밍

image

DTO Validation

@Builder
public record CommentSaveRequest(
    @NotBlank(message = "빈 문자열은 입력할 수 없습니다.")
    String content
) {

}
  • jakarta.validation.constraints 패키지의 어노테이션을 활용
  • message 필수 입력

Service DTO

패키지 구조 및 클래스 네이밍

image

Controller DTO <-> Service DTO <-> Entity의 변환

Controller DTO <-> Service DTO 변환

@Mapper(
    componentModel = "spring",
    injectionStrategy = InjectionStrategy.CONSTRUCTOR,
    unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface TripDtoMapper {

    // Request
    TripSaveDto of(TripSaveRequest request);

    TripUpdateDto of(TripUpdateRequest request);

    // Response
    TripResponse of(TripItineraryInfoDto dto);

    TripSummaryResponse of(TripInfoDto dto);

    TripDetailResponse of(TripDetailDto dto);

    List<TripSummaryResponse> of(List<TripInfoDto> dto);

    default Page<TripSummaryResponse> of(Page<TripInfoDto> dto) {
        return dto.map(this::of);
    }
}
  • MapStruct 라이브러리를 사용한 Mapper 인터페이스를 통해 변환합니다.
  • 인터페이스로 변환 내용(매개변수, 반환형)을 선언하면 자동으로 변환 코드가 짜여집니다.

Service DTO <-> Entity 변환

@Builder
public record CommentUpdateDto(
    String content
) {

    public Comment toEntity() {
        return Comment.builder()
            .content(content)
            .build();
    }
}
@Builder
public record CommentInfoDto(
    Long id,
    TripInfoDto trip,
    MemberInfoDto member,
    String content
) {

    public static CommentInfoDto from(Comment comment) {
        return CommentInfoDto.builder()
            .id(comment.getId())
            .member(MemberInfoDto.from(comment.getMember()))
            .trip(TripInfoDto.from(comment.getTrip()))
            .content(comment.getContent())
            .build();
    }
}
  • Service DTO의 from, toEntity 메서드를 통해 변환합니다.
  • from: Entity -> Service DTO
  • to: Service DTO -> Entity

테스트 코드

  • Repository: @Query 어노테이션 등을 사용한 메서드에 대해 테스트 코드 작성 필수
  • Service: 단순 Repository 메서드 호출 이상의 로직을 수행하는 메서드에 대해 테스트 코드 작성 필수
  • Controller: 모든 public 메서드 테스트 코드 작성 필수

Repository 테스트 코드 예시

@DataJpaTest
public class CommentRepositoryTests {

    @Autowired
    private CommentRepository commentRepository;

    @Test
    void findAll() {
        // given
        Comment comment = new Comment("example");
        Comment savedComment = commentRepository.save(comment);

        // when
        List<Comment> comments = commentRepository.findAll();

        // then
        assertThat(comments.size()).isEqualTo(1);
        assertThat(comments.get(0)).isEqualTo(savedComment);
    }

    @Test
    void findById() {
        // given
        Comment comment = new Comment("example");
        Comment savedComment = commentRepository.save(comment);

        // when
        Comment foundComment = commentRepository.findById(savedComment.getId()).orElseThrow();

        // then
        assertThat(foundComment).isEqualTo(savedComment);
    }

    @Test
    void deleteById() {
        // given
        Comment comment = new Comment("example");
        Comment savedComment = commentRepository.save(comment);

        // when
        commentRepository.deleteById(savedComment.getId());

        // then
        assertThat(commentRepository.count()).isEqualTo(0);
    }

    @Test
    void save() {
        // given
        Comment comment = new Comment("example");

        // when
        commentRepository.save(comment);

        // then
        List<Comment> comments = commentRepository.findAll();
        assertThat(comments.size()).isEqualTo(1);
        assertThat(comments.get(0).getComment()).isEqualTo("example");
    }
}

Service 테스트 코드 예시

@ExtendWith(MockitoExtension.class)
public class CommentServiceTests {

    @InjectMocks
    private CommentService commentService;

    @Mock
    private CommentRepository commentRepository;

    @Test
    void editComment_정상_동작() {
        // given
        Long commentId = 1L;
        Comment comment = new Comment("example");
        given(commentRepository.findById(commentId)) // comemntId에 대해 findById가 호출되면
            .willReturn(Optional.of(comment)); // Optional.of(comment)를 반환하도록
        given(commentRepository.save(any())) // 어떤 매개변수든 save가 호출되면
            .willAnswer(invocation -> invocation.getArguments()[0]); // 첫번째 매개변수를 그대로 반환하도록

        // when
        Comment editedComment = commentService.editComment(1L, "edited");

        // then
        assertThat(editedComment.getComment()).isEqualTo("edited");
    }

    @Test
    void editComment_없는_댓글을_수정하려하면_예외() {
        // given
        given(commentRepository.findById(any()))
            .willReturn(Optional.empty());

        // when, then
        assertThatThrownBy(() -> {
            commentService.editComment(0L, "edited");
        }).isInstanceOf(NoSuchCommentException.class);
    }
}

Controller 테스트 코드 예시

// build.gradle에 아래 디펜던시 추가 필수
testImplementation 'io.rest-assured:rest-assured:5.1.1'
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class CommentControllerTests {

    @LocalServerPort
    private int port;

    @Autowired
    private CommentRepository commentRepository;

    @BeforeEach
    void setUp() {
        RestAssured.port = port;
    }

    @Test
    void 댓글_목록() {
        // given
        String url = "/api/comments";
        Comment givenComment = commentRepository.save(new Comment("example1"));

        // when
        ExtractableResponse<Response> response = RestAssured
            .given().log().all()
            .when()
                .get(url)
            .then().log().all()
            .extract();

        // then
        JsonPath jsonPath = response.jsonPath();
        String status = jsonPath.getString("status");
        List<CommentResponse> data = jsonPath.getList("data", CommentResponse.class);

        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());

        assertSoftly((softly) -> {
            softly.assertThat(status).isEqualTo("SUCCESS");
            softly.assertThat(data).hasSize(1);
            softly.assertThat(data).contains(toCommentResponse(givenComment));
        });
    }

    @Test
    void 댓글_작성() {
        // given
        String url = "/api/comments";
        CommentRequest request = new CommentRequest("example");

        // when
        ExtractableResponse<Response> response = RestAssured
            .given().log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(request)
            .when()
                .post(url)
            .then().log().all()
            .extract();

        // then
        JsonPath jsonPath = response.jsonPath();
        String status = jsonPath.getString("status");
        CommentResponse data = jsonPath.getObject("data", CommentResponse.class);

        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());

        assertSoftly((softly) -> {
            softly.assertThat(status).isEqualTo("SUCCESS");
            softly.assertThat(data.id()).isNotNull();
            softly.assertThat(data.comment()).isEqualTo("example");
        });
        // assertThat은 실패 시 다음 줄로 내려가지 않고 바로 종료되지만, assertSoftly는 실패해도 모든 줄을 실행한다
    }

    @Test
    void 댓글_수정() {
        // given
        Comment givenComment = commentRepository.save(new Comment("before edit"));
        String url = "/api/comments/" + givenComment.getId();

        CommentRequest request = new CommentRequest("after edit");

        // when
        ExtractableResponse<Response> response = RestAssured
            .given().log().all()
                .contentType(MediaType.APPLICATION_JSON_VALUE)
                .body(request)
            .when()
                .put(url)
            .then().log().all()
            .extract();

        // then
        JsonPath jsonPath = response.jsonPath();
        String status = jsonPath.getString("status");
        CommentResponse data = jsonPath.getObject("data", CommentResponse.class);

        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());

        assertSoftly((softly) -> {
            softly.assertThat(status).isEqualTo("SUCCESS");
            softly.assertThat(data.id()).isEqualTo(givenComment.getId());
            softly.assertThat(data.comment()).isEqualTo("after edit");
        });
    }

    @Test
    void 댓글_삭제() {
        // given
        Comment givenComment = commentRepository.save(new Comment("before edit"));
        String url = "/api/comments/" + givenComment.getId();

        // when
        ExtractableResponse<Response> response = RestAssured
            .given().log().all()
            .when()
                .delete(url)
            .then().log().all()
            .extract();

        // then
        JsonPath jsonPath = response.jsonPath();
        String status = jsonPath.getString("status");

        assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
        assertThat(status).isEqualTo("SUCCESS");

        assertThat(commentRepository.findById(givenComment.getId())).isNotPresent();
    }
}