Skip to content

Convention

Junha edited this page Nov 19, 2023 · 34 revisions

Branch Rule

feat/<issue-num>-<summary>

# Example
feat/1-init-project
feat/2-implement-performance

Only Allow Squash Merge to Main Branch

Branch Strategy

  • master => main
  • feature => feat

Commit Message

Issue: CI: commit message convention #14

Setup Guide

Git actionsLocal Git Hook을 통해 CI 진행

# <type>: <subject>
##### Subject 50 characters ################# -> |

# Body Message
######## Body 72 characters ####################################### -> |

# Issue Tracker Number or URL

# --- COMMIT END ---
# Type can be
#   feat    : new feature
#   fix     : bug fix
#   refactor: refactoring production code
#   style   : formatting, missing semi colons, etc; no code change
#   docs    : changes to documentation
#   test    : adding or refactoring tests
#             no productin code change
#   chore   : updating grunt tasks etc
#             no production code change

템플릿

Code: ktlint

Issue: CI: 코드 컨벤션 체크 #6

Git actionsLocal Git Hook을 통해 CI 진행

root = true

[*]
charset=utf-8
end_of_line=lf
indent_style=space
indent_size=4
insert_final_newline=true
disabled_rules=no-wildcard-imports,import-ordering,comment-spacing

[*.{kt,kts}]
ktlint_code_style = ktlint_official

.editconfig

Application Level Decision

Naming Rule

camelCase 를 기본적으로 사용하며 상수와 열거형(Enum)을 정의할 때는 대문자를 사용한다.

HTTP Response

Issue: 레이어간 데이터 전달 형식 정의

API Docs: https://f-lab-clone.github.io/ticketing-backend/

SUCCESS RESPONSE

HTTP_STATUS: 200, 201 ... 

{
    "timestamp": "2023-10-13T14:21:54.3281858Z",
    "message": "success",
    "data": {
         controller가 반환한 값이 들어가있다.
    },
    "path": "/events/1",
    "totalElements": null
}

SUCCESS처리 방식

ERROR RESPONSE

HTTP_STATUS: 400, 401, 403, 404, 500 ...
// 500 상황에서도 해당 format은 준수되어야 한다.

{
    "timestamp": "2023-10-13T14:43:22.1755822Z",
    "errorCode": 40000,
    "message": "해당 레코드를 찾을수 없습니다.",
    "path": "/reservations"
}

ERROR 처리 방식

애플리케이션 동작시 발생하는 에러에 대해 서버에서 의도된 response값을 반환하기 위하여 예상되는 오류들은 Eum class인 ErrorCodes에 정의되어있음.

enum class ErrorCodes(val status: HttpStatus, val message: String, val errorCode: Int) {
    // 400  Bad Request
    VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "유효성 검증에 실패하였습니다.", 10001),
    MESSAGE_NOT_READABLE(HttpStatus.BAD_REQUEST, "올바른 형식의 요청이 아닙니다", 10002),
    // 404  Not Found
    ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 레코드를 찾을수 없습니다.", 40000),
    ...
}

ErrorCodes를 필드로 갖는 CustomException을 정의하여 비지니스 로직에서 문제가 생겼을때 Serivce Layer에서 errorCode와 함께 CustomException을 호출한다.

userRepository.findByEmail(username) ?: let { throw CustomException(ErrorCodes.ENTITY_NOT_FOUND) }

위에서 발생한 CustomException은 RestControllerAdbice가 Handle하여 의도한 response를 반환한다.
Controller에서 매 메소드마다 try-catch로 발생한 Exception을 처리 하는거 보다 훨씬 깔끔하다.

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(value = [CustomException::class])
    fun handleCustomException(exception: CustomException, request: HttpServletRequest): ResponseEntity<ErrorResponseDTO> {
        val errorCode = exception.errorCode
        val errorDto = ErrorResponseDTO(
            errorCode = exception.errorCode.errorCode,
            message = exception.errorCode.message,
            path = request.requestURI
        )
        return ResponseEntity(errorDto, errorCode.status)
    }
...
}

Serialization

기본적으로 모든 통신은 application/json 형식으로 이루어지며 Gson을 사용한다.

코틀린에서 json (de)serializer은 kotlinx(jetbrains)와 gson(google)이 대표적으로 사용된다. Jetbrains사에서 만든 kotlinx를 채택하지 않은 이유는 data class로 정의한 dto 객체에 @serializable 이라는 어노테이션을 선언해야 하며, kotlinx.LocalDateTime 객체가 따로 존재하여 java.time.OffesetDateTime 로 컨버팅시 필요없는 보일러 플레이트 코드가 발생하여 직관적인 Gson을 사용하기로 했다.

OffsetDatetime Conversion

HTTP 통신 중 JSON 포맷은 Gson을 통해 Serialization/Deserialization 된다. 이때 외부에서 들어오는 ISO8601 Datetime String 중 Timezone offset이 UTC(+00:00)이 아닌 경우 UTC 시간으로 자동 변경된다.

{
  “datetime”: “2000-01-01T09:00:00.001+09:00”
} 

위의 DatetimeString은 아래와 같은 UTC 시간으로 변경되어 Serialization 된다.

DatetimeObj = OffsetDatetime.of(2000,1,1,0,0,0,1,”00:00”)

세부 내용은 config/WebConfig.kt, config/GsonConfig.kt 를 확인한다. 2000-01-01T00:00:00.001Z는 2000-01-01T00:00:00.001+00:00 과 같은 의미이며 Z는 UTC timezone을 의미한다. **만약 Timezone Offset 정보(+00:00)가 Datetime String에 존재하지 않는다면 파싱시 IllegalArgumentationException 을 발생하게 된다.

java.time.OffesetDateTime 객체를 직렬화(역직렬화) 할때 ISO8601 포맷으로 변환(파싱) 해야 한다. 아래 로직은 Gson의 JsonSerializer<T>, JsonDeserializer<T>를 상속받아 처리한다.

class DateTimeConverter : JsonSerializer< OffesetDateTime?>, JsonDeserializer< OffesetDateTime?> {
    private val FORMATTER = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"
        .let { DateTimeFormatter.ofPattern(it) }

    override fun serialize(src: OffesetDateTime?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement {
        return JsonPrimitive(FORMATTER.format(src))
    }

    override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): OffesetDateTime? {
        return FORMATTER.parse(json!!.asJsonPrimitive.asString) as LocalDateTime?
    }
}
@Test
fun `All API only returns OffsetDateTime in UTC without any offset info`() {
        val performanceCreateRequest = "{\"title\":\"test title\"," +
            "\"date\":\"2022-09-01T21:00:00.001+09:00\"," +
            "\"bookingStartTime\":\"2022-09-01T22:00:00.001+09:00\"," +
            "\"bookingEndTime\":\"2022-09-01T23:00:00.001+09:00\"," +
            "\"maxAttendees\":10}"

        mockMvc.perform(
            post("/performances")
                .contentType(MediaType.APPLICATION_JSON)
                .content(performanceCreateRequest)
        )
            .andExpect(status().isOk)
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.date").value("2022-09-01T12:00:00.001Z"))
            .andExpect(jsonPath("$.bookingStartTime").value("2022-09-01T13:00:00.001Z"))
            .andExpect(jsonPath("$.bookingEndTime").value("2022-09-01T14:00:00.001Z"))
} 

Feat/39 Time 컨벤션 정의

Datetime Format Rule

java.time.OffesetDateTime 을 기본적으로 사용하며 문자열로 변환시 포맷은 ISO8601 형식을 따르고 마이크로세컨드 단위까지 표시한다.

java.time.OffesetDateTime.now(clock) 메서드를 통해 UTC 표준시를 사용한다.

문자열로 포맷으로 변환 및 LocalDatetime 객체로의 변환은 아래와 같이 사용할 수 있다.

val FORMATTER = "yyyy-MM-dd'T'HH:mm:ss.SSSXXX".let { DateTimeFormatter.ofPattern(it) }
// OffesetDateTime to String
DateTimeString = FORMATTER.format(DateTimeObj) 
// String to OffesetDateTime
DateTimeObj = FORMATTER.parse(DateTimeString)

Test

각 레이어의 테스트는 본질적으로 Application Context를 구성하기에 순수한 단위 테스트가 아니라고 생각할 수 있다. 하지만 실행되는 코드에 집중하지 않고 검증하는 테스트에 집중한다면 단위 테스트이다. 이러한 모호함을 없애기 위해, 구글의 테스트 구분 방법을 사용해 해당 테스트를 구분한다.

구글은 단위테스트와 통합테스트 대신 작은/중간/큰 테스트라고 정의한다. 작은 테스트는 단 하나의 프로세스에서 실행되는 테스트를 의미한다. 또한 Sleep, I/O 연산, 네트워크에 접근해선 안된다. 중간 크기 테스트는 여러 프로세스와 스레드 이용이 가능하다 즉 데이터베이스 인스턴스의 실행이 가능하다. 테스트를 크기로 구분하는 것은 명확하지만, 범위로 구분하는 것은 모호한 면이 있다. 구글의 정의에 따르면 단위 테스트는 대체로 작은 테스트에 속한다. - 구글 엔지니어는 어떻게 일하는가?

1. Controller Layer Test - 중간 크기 테스트

  • 컨트롤러 레이어는 웹과 맞붙어 있는 레이어로써, 외부 요인 차단이 불가능하다.
  • 통합테스트와 유닛 테스트 경계에 위치해 있다 (슬라이스 테스트)
  • src/test 하위 폴더에 작성

2. Service Layer Test - 작은 크기 테스트

  • Repository 객체 Mock up 후 테스트한다
  • src/test 하위 폴더에 작성

3. Repository Layer Test - 중간 크기 테스트

  • H2 DB가 아닌 Testcontainer를 이용
  • DB 인스턴스와의 상호작용도 테스트하기 때문에 통합 테스트
  • src/integrationTest 하위 폴더에 작성

참고