경험/회고

백엔드 개발 중 나만의 규칙

호야_ 2023. 8. 4. 23:43
728x90

개발을 할 때 유지보수하기 쉽게, 남이 봤을 때 직관적이게, 그렇지만 깔끔하게 구성하려고 노력해 봤다.

 

컨트롤러에서 어떤 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

 

  1. 의존성 역전 원칙 (Dependency Inversion Principle, DIP): 이 원칙에 따르면, 상위 수준의 모듈은 하위 수준의 모듈에 의존하면 안되며, 이들 모두는 추상화에 의존해야 합니다. 즉, 구체적인 구현에 의존하는 것이 아니라 인터페이스에 의존하도록 설계하면, 코드의 재사용성, 유연성, 유지 보수성이 향상된다.
  2. 다형성 (Polymorphism): 인터페이스를 통해 다양한 구현을 하나의 인터페이스로 관리할 수 있어서 코드의 유연성을 향상시킬 수 있다. 이를 통해 런타임에서 동적으로 구현을 바꿔주는 등의 다형성을 실현할 수 있다.
  3. 테스트 용이성: 인터페이스를 사용하면, 테스트에서 실제 구현 대신 모의 객체(Mock Object)를 쉽게 사용할 수 있다. 이를 통해 단위 테스트의 용이성이 증가한다.
  4. 코드의 명확성: 인터페이스를 보면 어떤 메소드들이 구현되어야 하는지 쉽게 파악할 수 있으므로 코드의 가독성이 향상된다.
  5. 결합도 감소: 인터페이스를 사용하면 구체적인 클래스에 의존하는 것이 아니라 인터페이스에 의존하게 되므로, 클래스 간의 결합도를 낮출 수 있다. 이는 코드 변경 시 영향을 최소화하고, 유지 보수성을 향상시킨다.

 

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절 안에 줄줄이 쓰는 것보다 저렇게 함수로 따로 쓰면 재사용도 할 수 있고 직관적으로 어떤 조건이 추가되는지 알 수 있다.

 

 

 

지극히 개인적인 주장이며, 코딩 스타일은 당연히 정답이 없습니다. 언제나 잘못된 설명이나 부족한 부분에 대한 피드백은 환영입니다🤍

728x90