개발을 할 때 유지보수하기 쉽게, 남이 봤을 때 직관적이게, 그렇지만 깔끔하게 구성하려고 노력해 봤다.
컨트롤러에서 어떤 API인지 직관적으로 나타내보자.
우선 api문서를 작성하던 어떤 메서드가 있는지 확인하던 컨트롤러를 가장 먼저 보게 될 것이다. 물론 함수명이나 컨트롤러에 작성된 로직들로만 이게 어떤 역할을 하는 api인지 알면 좋지만 그렇지 못할 수도 있기에 주석을 작성해 봤다.
@RestController
@RequestMapping("resource")
class ResourceController(
private val resourceService: ResourceService
) {
/** 페이징 API **/
@GetMapping("/list")
fun loadPagedResource(
@ModelAttribute @Valid resourceRequestDto: ResourceRequestDto,
pageRequest: PagingRequest
) = resourceService.loadPagedResource(resourceRequestDto, pageRequest)?.let {
ResponseEntity.ok(it)
} ?: ResponseEntity(HttpStatus.NO_CONTENT)
/** ID로 검색 API **/
@GetMapping("/{id}")
fun loadResourceById(@PathVariable id: Int) =
resourceService.loadResourceByIdToDto(id)
/** ID로 검색 후 부분 업데이트 API **/
@PatchMapping("")
fun updateResource(@RequestBody @Valid updateResourceDto: UpdateResourceDto) =
resourceService.updateResource(updateResourceDto)
/** ID로 검색 후 Soft Delete API **/
@PatchMapping("/{id}")
fun softDeleteResource(@PathVariable id: Int) =
resourceService.softDeleteResource(id)
/** 새로운 리소스 등록 API **/
@PostMapping("")
fun createResource(@RequestBody @Valid createResourceDto: CreateResourceDto): ResponseEntity<Void> {
resourceService.createResource(createResourceDto).let {
val uri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}")
.buildAndExpand(it).toUri()
return ResponseEntity.created(uri).build()
}
}
}
물론 이 예제들에서는 함수명으로만 유추가 가능하다. 하지만 유추하기 힘들 것 같을 때는 주석으로 처리해도 좋을 것 같다.
서비스에서 인터페이스와 주석을 사용해 보자.
interface ResourceService {
/** 검색 조건에 따른 페이징된 데이터 가져오기 **/
fun loadPagedResource(
resourceRequestDto: ResourceRequestDto,
pageRequest: PagingRequest
): Page<ResourcePagingDto>?
/** ID로 데이터 가져오기 **/
fun loadResourceByIdToDto(id: Int): ResourceDto
/** 데이터 수정 **/
fun updateResource(updateResourceDto: UpdateResourceDto)
/** 데이터 삭제 **/
fun softDeleteResource(id: Int)
/** 새로운 데이터 생성 **/
fun createResource(createResourceDto: CreateResourceDto): Int?
}
서비스 인터페이스는 구현체보다 비교적 로직이 적고 간단간단하게 작성된다. 그렇기에 주석을 적어도 복잡하다는 느낌이 안 들기 때문에 인터페이스에 주석으로 기능을 적어주면 좋은 것 같다. 그리고 `/****/` 주석을 사용하면 IDE 기능에서 컨트롤러에서 함수 위에 마우스를 가지고 가면 주석 설명이 나온다. 이렇게 되면 컨트롤러에서 정말 자세히 어떤 기능을 하는지 알 수 있게 된다.
인터페이스 장점 TMI
- 의존성 역전 원칙 (Dependency Inversion Principle, DIP): 이 원칙에 따르면, 상위 수준의 모듈은 하위 수준의 모듈에 의존하면 안되며, 이들 모두는 추상화에 의존해야 합니다. 즉, 구체적인 구현에 의존하는 것이 아니라 인터페이스에 의존하도록 설계하면, 코드의 재사용성, 유연성, 유지 보수성이 향상된다.
- 다형성 (Polymorphism): 인터페이스를 통해 다양한 구현을 하나의 인터페이스로 관리할 수 있어서 코드의 유연성을 향상시킬 수 있다. 이를 통해 런타임에서 동적으로 구현을 바꿔주는 등의 다형성을 실현할 수 있다.
- 테스트 용이성: 인터페이스를 사용하면, 테스트에서 실제 구현 대신 모의 객체(Mock Object)를 쉽게 사용할 수 있다. 이를 통해 단위 테스트의 용이성이 증가한다.
- 코드의 명확성: 인터페이스를 보면 어떤 메소드들이 구현되어야 하는지 쉽게 파악할 수 있으므로 코드의 가독성이 향상된다.
- 결합도 감소: 인터페이스를 사용하면 구체적인 클래스에 의존하는 것이 아니라 인터페이스에 의존하게 되므로, 클래스 간의 결합도를 낮출 수 있다. 이는 코드 변경 시 영향을 최소화하고, 유지 보수성을 향상시킨다.
QueryDSL 사용할 때 함수명을 직관적으로 사용해 보자.
@Repository
class ResourceRepositoryImpl(private val queryFactory: JPAQueryFactory) : ResourceRepositoryCustom {
override fun findByAttributes(
attribute1: String,
attribute2: String?,
attribute3: String?,
startDate: LocalDate?,
endDate: LocalDate?,
pageable: Pageable
): Page<Resource> {
val qResource = QResource.resource
val query = queryFactory
.selectFrom(qResource)
.where(
equalsIfNotNull(qResource.attribute1, attribute1),
equalsIfNotNull(qResource.attribute2, attribute2),
equalsIfNotNull(qResource.attribute3, attribute3),
isNull(qResource.deletedAt),
betweenIfNotNull(qResource.createdAt, startDate, endDate)
)
.orderBy(qResource.createdAt.desc())
.offset(pageable.pageNumber.toLong())
.limit(pageable.pageSize.toLong())
val resourceList = query.fetch()
val countQuery = queryFactory
.selectFrom(qResource)
.where(
equalsIfNotNull(qResource.attribute1, attribute1),
equalsIfNotNull(qResource.attribute2, attribute2),
equalsIfNotNull(qResource.attribute3, attribute3),
isNull(qResource.deletedAt),
betweenIfNotNull(qResource.createdAt, startDate, endDate)
)
val total = countQuery.fetch().count().toLong()
return PageImpl(resourceList, pageable, total)
}
private fun equalsIfNotNull(expression: StringPath, value: String?): BooleanExpression? {
return value?.let { expression.eq(value) }
}
private fun isNull(expression: DatePath<LocalDate>): BooleanExpression {
return expression.isNull
}
private fun betweenIfNotNull(
expression: DatePath<LocalDate>,
start: LocalDate?,
end: LocalDate?
): BooleanExpression? {
return if (start != null && end != null) {
expression.between(start, end)
} else null
}
}
우선 이 함수는 파라미터가 null이면 조건에 추가하지 않고 null이 아니면 추가하는 동적 쿼리로 구현했다. 여기서 보여주고 싶었던 것은 `equalsIfNotNull`, `isNull`, betweenIfNotNull`처럼 where절 안에 함수명만 보고도 어떤 동작을 하는지 따로 구현하는 것이 좋다고 생각한다. where절 안에 줄줄이 쓰는 것보다 저렇게 함수로 따로 쓰면 재사용도 할 수 있고 직관적으로 어떤 조건이 추가되는지 알 수 있다.
지극히 개인적인 주장이며, 코딩 스타일은 당연히 정답이 없습니다. 언제나 잘못된 설명이나 부족한 부분에 대한 피드백은 환영입니다🤍