Owen Labs

너구리소굴 백엔드 1차 개발 회고 (1) - 인증, 비즈니스 로직 본문

프로젝트/너구리소굴

너구리소굴 백엔드 1차 개발 회고 (1) - 인증, 비즈니스 로직

parkjg20 2022. 8. 30. 23:33

 

2022.07.21에 작성한 글에서 너구리소굴 백엔드 프레임워크를 nest.js에서 Spring boot로 변경하기로 결정했었다.

 

그리고 2022.08.29일 약 39일동안 백엔드 1차 개발을 수행했는데, 1차 범위에서 수행했던 작업들과 감상을 간단하게나마 기록하려고한다.

 

작업을 요약해보자면 아래 다섯가지로 분류할 수 있다.

- 인증

- 비즈니스 로직(Application Layer)

- Presentation Layer

- Persistence Layer

- 기타 초기 설정

 

이 중 인증과 비즈니스 로직에 대한 회고를 이번 글에서 작성하려고 한다.


인증

WebSecurityConfigurerAdapter is Deprecated

1차 개발 범위 내에서 가장 오랜 시간이 걸렸던 작업 범위는 인증 도메인이었다. 기존에 본인은 시큐리티 필터를 구성할 때 AbstractAuthenticationProcessingFilter를 확장하여 구현했는데, 해당 필터는 AuthenticationManager를 주입해주지 않으면 "authenticationManager must be specified"라는 문구와 함께 Exception이 발생한다.

// AbstractAuthenticationProcessingFilter에서 authenticationManager가 주입되었는지 확인하는 코드
@Override
public void afterPropertiesSet() {
	Assert.notNull(this.authenticationManager, "authenticationManager must be specified");
}

Security 2.7 이하 버전에서는 보통 커스텀 필터를 추가하려면 SecurityConfiguration 클래스에서 필터를 직접 인스턴스화해서 Bean으로 등록해주었다. 그리고 이 SecurityConfiguration는 WebSecurityConfigurerAdapter를 상속받아 확장하였는데, 이 Adapter 클래스에서 authenticationManager를 주입받는 코드를 미리 구현해 두었기 때문에 의존성을 수동으로 주입하는 것도 문제 없었다.

// get authentication manager 
public AuthenticationManager authenticationManagerBean() throws Exception {
    return new AuthenticationManagerDelegator(this.authenticationBuilder, this.context);
}

protected AuthenticationManager authenticationManager() throws Exception {
    if (!this.authenticationManagerInitialized) {
        this.configure(this.localConfigureAuthenticationBldr);
        if (this.disableLocalConfigureAuthenticationBldr) {
            this.authenticationManager = this.authenticationConfiguration.getAuthenticationManager();
        } else {
            this.authenticationManager = (AuthenticationManager)this.localConfigureAuthenticationBldr.build();
        }

        this.authenticationManagerInitialized = true;
    }

    return this.authenticationManager;
}

 

 하지만 2.7 버전에서는 이 WebSecurityConfigurerAdapter를 이용한 Configuration이 Deprecated되었기 때문에 더이상 이런 방법으로 구현 할 수는 없었다. 그래서 AuthenticationManagerBuilder를 이용해 직접 생성하는 방법, CustomFilter를 등록하는 bean method에서 AuthenticationManager를 주입받는 방법 등 여러 방법들을 시도해보았는데, 모두 실패하고 구현이 깔끔하지 못했다.

 

어떻게 AuthenticationManager를 주입받아야할 지 고민하던 중 한 코드를 발견했는데, 사실 이 코드는 구글링 중 많이 마주쳤던 코드였다.

 

그래서 이제는 AbstractHttpConfigurer<T, HttpSecurity> 클래스를 이용하자

class AnonymousAuthenticationFilterConfigurer : AbstractHttpConfigurer<AnonymousAuthenticationFilterConfigurer, HttpSecurity>() {
    companion object {
        fun getBean(): AnonymousAuthenticationFilterConfigurer {
            return AnonymousAuthenticationFilterConfigurer()
        }
    }

    override fun configure(builder: HttpSecurity?) {
        val filter = AnonymousAuthenticationFilter(NeoguriAuthenticationEntryPoint());
        // 내가 찾던 그 구현부
        filter.authenticationManager = builder!!.getSharedObject(AuthenticationManager::class.java)
        builder.addFilterAfter(filter, RequireLoginFilter::class.java)
    }
}

위 코드는 당시 발견한 예제를 응용해 본인이 직접 구현인 너구리소굴 API 내 익명 인증 처리 필터다. 바로 주석된 저 라인 한 줄을 찾기 위해 그 시간을 썼는데, 대충 보고 여러번 지나갔던 그 코드에 내가 찾던 정답이 숨어있었다. 발견하자마자 집에 도착하면 시도해봐야겠다고 메모해뒀고, 도착하자마자 시도해보았는데 예상대로 동작하는걸 확인했다.

 

@Bean
@Throws(Exception::class)
fun filterChain(
    http: HttpSecurity?
): SecurityFilterChain {
   
   /** ... */

    http.apply(AccessTokenAuthenticationFilter.AccessTokenAuthenticationFilterConfigurer.getBean())
    http.apply(RequireLoginFilter.RequireLoginFilterConfigurer.getBean())
    http.apply(AnonymousAuthenticationFilter.AnonymousAuthenticationFilterConfigurer.getBean())

    /** ... */
}

Security Configuration 클래스에서 filterChain을 설정하는 메소드에 위와 같이 작성해주니 필터 등록에도 성공했고 인증 기능 구현을 마무리할 수 있었다.

 

별거 아닌 한 줄을 찾지 못해서 삽질을 꽤 많이 했지만 덕분에 시큐리티 내부 구현을 좀 많이 들여다 보기도 했으니 시간을 마냥 버렸다고 생각하지는 않으니 나름 보람찬 경험이었다.

 

EntryPoint에서는 Security 소스 내에서 AuthenticationException이 발생한 경우와, API Handler에서 예외가 발생했을 때의 ResponseBody 형태를 맞춰주었다. 자세한 내용은 Presentation Layer 쪽에서 설명하려고한다.

 


비즈니스 로직

애초 계획했던 것처럼 Application layer에 UseCase를 적용해 비즈니스 로직을 작성했다. 구현한 UseCase들은 아래와 같다

 

백엔드 1차 범위에서 구현한 UseCase

인증 도메인 로직

- 로그인: Client credentials를 이용한 Access Token 발행 

- 갱신: Refresh Token을 이용한 AccessToken 발행

 

유저 도메인 로직

- 회원가입

- 유저 주소 수정

 

위 네가지 로직인데, 회원가입, 로그인, 갱신은 그렇다 치고 유저 주소 수정을 왜 먼저 구현했는지 궁금할 수 있다.

 

사실 별 이유 없고 그냥 인증 테스트 할만한 기능이 뭐가 있을까... 권한이 있어야하니 그냥 유저정보를 조회하는 API를 미리 만들기에는 인증 테스트를 제대로 할 수 없을 것 같았다. 다른 이유로 포장할 수는 있는데 굳이 여기에는 안적겠다.

 

UseCase Example

/**
 * UserAddUseCaseInterface와 UserAddUseCase
 */
interface UserAddUseCaseInterface {
    @Throws(DuplicatedEntityException::class)
    fun execute(userAddDto: UserAddDto): UserDto
}

@Service
class UserAddUseCase(
    val userRepository: UserEntityRepositoryInterface
) : UserAddUseCaseInterface {
    override fun execute(userAddDto: UserAddDto): UserDto {

        val closure =
            @Transactional(isolation = Isolation.READ_COMMITTED)
            fun(addDto: UserAddDto): UserDto {
                return UserDto.of(
                    userRepository.save(User.create(addDto))
                )
            }

        try {
            return closure(userAddDto)
        } catch (e: DataIntegrityViolationException) {
            if (ConstraintViolationException::class.java.isAssignableFrom(e.cause!!::class.java)
                && (e.cause as ConstraintViolationException).constraintName.contains("UNIQUE")) {
                // constraint check

                throw DuplicatedEntityException()
            }

            throw e
        } catch (e: Exception) {
            e.printStackTrace()
            throw e
        }
    }
}

이전에 개발 사전 계획에서 서술했던 것처럼 본인이 작업하면서 비즈니스 로직을 작성하는 데 가장 좋은 경험을 했다고 느꼈던 UseCase를 도입했다. 소스가 깔끔해서 마음에 든다.

DuplicateKeyException 처리

 회원가입 기능을 개발하면서도 한가지 간단한 고충을 겪었는데, 바로 DuplicateKeyException이다. MyBatis를 이용한 쿼리 수행 중 duplicated(Unique constraint 위반)가 발생하는 경우 DuplicateKeyException을 던졌고, 이를 처리했던 경험이 있다. 당연히 JPA에서도 DuplicateKeyException이 나올 것이라 기대하고 소스를 작성했었다.

 그리고 테스트 해보니 해당 catch문이 실행되지 않았다. 삽질의 시작을 알리는 결과였는데, Exception을 잡는 catch를 하나 추가해 Debugger로 어떤 타입이 throw되었는지 확인해봤다.

 

 실제로 던져진 예외는 DataIntegrityViolationException였다. HIbernate에서는 현재 의존성 버전 문제인지 DuplicateKeyException을 던지지 않고 그 부모 타입인 DataIntegrityViolationException를 throw하도록 구현이 되어있는 것 같았다. 그래서 어쩔수 없이 위 코드와 같이 작성했다. 맘에 들진 않지만 나중에 DuplicateKeyException으로 throw 할 수 있는 방법을 찾으면 리팩토링 해봐야겠다

 

Exception은 특정한 상황임을 나타내도록 작성한다.

 메소드에서 어떠한 예외가 발생할 때, 그냥 무슨 예외인지 명시하지 않고 그저 귀찮으니까 Exception이라고 명시하고 넘어가는 개발자들이 실제로 있다... 본인이다 싶으면 반성하시라. 그게 편하긴 하겠지, 물론 그 기능을 개발할 당시 까지만!!!!!!!!!!!!!!

 한 비즈니스 로직. 특히 범용 비즈니스 로직에서는 더더욱 특정한 상황만을 위한 Exception이 존재해야한다고 생각한다. 예를 들어 회원가입 전체 프로세스에서 발생할 수 있는 예외 중에는 NonNull 타입에 null이 들어간 경우(NullPointerException), 그리고 회원 정보가 중복된 경우(DuplicateKeyException) 등이 있을것이다. Exception 또는 RuntimeException의 경우 거의 대부분 Exception들의 조상 클래스이기 때문에 언급한 Exception들을 각각 Throws에 명시해주지 않고, 하나의 예외만 명시할 수 있다. 여기까지는 문제 없다고 생각할 수도 있을 것이다.

 그런데, 이 메소드를 호출하고싶은 다른 객체의 입장에서는 인터페이스에 의존하게 될 것인데 이 때 내부 구현을 바로 확인할 수 없으니 무슨 Exception이 나올지도 모른다. 그럼 구현체들을 일일히 찾아가면서 무슨 예외를 던지는지 파악해야하는데 이것도 보통 일은 아닐 것이 눈에 훤하다.

 


 

 

돌이켜 생각해보니 적고 싶었던 말이 뒤늦게 떠올라 수정을 거듭하고 있는데, 다음 회고는 기능을 마칠때마다 미리미리 작성해두는 방법도 시도해봐야겠다