Spring Boot 웹 애플리케이션 개발 중

발생한 핵심 문제 해결 경험

JWT 인증과 JSP 데이터 바인딩 오류

💡 프로젝트 개요 및 당면 과제

Spring Boot와 Spring Security를 활용하여 회원 관리 시스템을 구축하는 과정에서 두 가지 주요 기술적 난관에 직면했습니다. 첫 번째는 JWT(JSON Web Token) 기반 인증 시스템을 구현하던 중 발생한
UnsatisfiedDependencyException으로, UserDetailsService 빈이 누락되어 애플리케이션 시작 자체가 불가능한 문제였습니다.
두 번째는 사용자 프로필 페이지를 JSP로 개발하던 중 발생한 PropertyNotFoundException으로, DTO와 View 간의 데이터 불일치로 인해 런타임 오류가 발생했습니다.

이 포스팅에서는 이 두 가지 핵심 문제를 어떻게 분석하고 해결했는지 상세히 공유합니다.
이 과정에서 Spring Security의 JWT 인증 흐름, Spring의 빈 관리 메커니즘, 그리고 웹 프레젠테이션 계층에서의 데이터 바인딩 중요성에 대한 깊이 있는 이해를 얻을 수 있었습니다.


🎯 문제 해결 1: JWT 인증

UnsatisfiedDependencyException (UserDetailsService 빈 누락)

🚨 문제 상황

애플리케이션을 시작할 때 다음과 같은 에러 로그와 함께 서버가 구동되지 않았습니다.

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'jwtAuthenticationFilter': Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'jwtTokenProvider': Unsatisfied dependency expressed through constructor parameter 2: No qualifying bean of type 'org.springframework.security.core.userdetails.UserDetailsService' available
  • 문제 원인: JwtTokenProvider 클래스가 UserDetailsService 타입의 빈을 주입받으려 했지만, Spring 컨테이너에 해당 빈이 정의되어 있지 않아 의존성 주입(Dependency Injection)에 실패한 것입니다.
    JwtTokenProvider는 JWT 토큰을 검증하고 인증 객체를 생성하는 과정에서 사용자 정보를 조회하기 위해 UserDetailsService를 필요로 합니다.

🛠 해결 과정: UserDetailsService 구현 및 통합

Spring Security에서 사용자 정보를 로드하는 핵심 인터페이스인 UserDetailsService를 기존 MemberApplicationService에 구현하여 문제를 해결했습니다.

  • UserDetailsService 인터페이스 구현

    MemberApplicationService가 UserDetailsService 인터페이스를 구현하고, loadUserByUsername 메서드를 오버라이드하도록 수정했습니다.
@Service
@Transactional(readOnly = true)
public class MemberApplicationService implements UserDetailsService {
    // ... (기존 필드 및 생성자)

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("Loading user by username: {}", username);
        Member member = memberDomainService.findByUsername(username)
                .orElseThrow(() -> {
                    log.warn("User not found: {}", username);
                    return new UsernameNotFoundException("User not found with username: " + username);
                });

        // Member 엔티티를 Spring Security의 UserDetails 객체로 변환
        return User.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(member.getRole().name())
                .accountLocked(member.getStatus() == MemberStatus.SUSPENDED)
                .disabled(member.getStatus() != MemberStatus.APPROVED)
                .build();
    }

    // ... (기존 회원 관리 메서드들)
}

주요 변경 사항

  • UserDetailsService를 구현함으로써 MemberApplicationService 자체가 UserDetailsService 빈으로 등록될 수 있도록 했습니다.

  • loadUserByUsername 메서드 내에서 memberDomainService를 통해 사용자를 조회하고, 조회된 Member 객체를 Spring Security의 User 객체로 변환하여 반환하도록 했습니다.
    이는 Spring Security가 사용자 인증 정보를 처리하는 데 필수적인 과정입니다.

대안적 고려 (단일 책임 원칙)

이 해결 방식 외에, UserDetailsService 구현을 위한 별도의 UserDetailsServiceImpl 클래스를 생성하여 책임을 분리하는 방법도 고려했습니다.
이는 단일 책임 원칙을 준수하여 코드의 응집도를 높이고 유지보수를 용이하게 할 수 있습니다.
하지만 현재 프로젝트의 규모와 컨텍스트를 고려하여 MemberApplicationService에 통합하는 방식을 선택했습니다.

환경 확인 및 캐시 정리

코드 수정 후, 패키지 스캔이 정상적으로 이루어지는지 확인하고, 혹시 모를 의존성 캐시 문제를 해결하기 위해 Gradle clean build --refresh-dependencies 명령과 IntelliJ IDEA 캐시 무효화를 진행했습니다.

✅ 결과 및 학습

UserDetailsService를 성공적으로 구현하고 빈으로 등록함으로써 UnsatisfiedDependencyException을 해결하고 애플리케이션을 정상적으로 구동할 수 있었습니다.
이 과정을 통해 Spring Security에서 UserDetailsService가 사용자 인증 정보를 제공하는 핵심 컴포넌트이며, JWT 인증 흐름에서 사용자 정보를 조회하는 데 필수적인 역할을 한다는 것을 명확히 이해했습니다.
또한, Spring의 빈 관리와 의존성 주입 원리를 실질적으로 경험하며 에러 디버깅 능력을 향상시킬 수 있었습니다.

🎯 문제 해결 2

JSP PropertyNotFoundException

(DTO와 View 데이터 불일치)

🚨 문제 상황

회원 프로필 페이지를 개발하던 중, 페이지 접근 시 다음과 같은 500 에러가 발생했습니다.

Property [apiKey] not found on type [com.antock.api.member.application.dto.response.MemberResponse]
jakarta.el.PropertyNotFoundException: Property [apiKey] not found on type [com.antock.api.member.application.dto.response.MemberResponse]

에러 발생 위치

  • JSP 템플릿의 특정 라인:

  • 근본 원인: JSP에서 ${member.apiKey}라는 표현식을 사용하여 MemberResponse DTO 객체의 apiKey 속성에 접근하려 했지만, 실제 MemberResponse DTO 클래스에는 apiKey 필드가 정의되어 있지 않아 발생한 오류입니다.
    Spring Expression Language는 내부적으로 getApiKey()와 같은 getter 메서드를 호출하는데, 해당 메서드가 존재하지 않았던 것입니다.

🛠 MemberResponse DTO 수정

MemberResponse DTO 클래스에 누락된 apiKey 필드를 추가하고, from() 정적 팩토리 메서드에서 해당 필드를 올바르게 매핑하도록 수정하여 문제를 해결했습니다.

  • MemberResponse DTO 분석

    초기 MemberResponse DTO는 다음과 같았습니다.
@Getter
@Builder
public class MemberResponse {
    private Long id;
    // ... (다른 필드들)
    // apiKey 필드 누락!
}

apiKey 필드 추가 및 매핑

MemberResponse DTO에 private String apiKey; 필드를 추가하고, from() 메서드에서 Member 엔티티의 getApiKey() 값을 매핑하도록 수정했습니다.

@Getter
@Builder
public class MemberResponse {
    private Long id;
    private String username;
    private String nickname;
    private String email;
    private MemberStatus status;
    private Role role;
    private Date createDate;
    private LocalDateTime modifyDate;
    private Date lastLoginAt;
    private Date approvedAt;
    private String apiKey; // ✅ 필드 추가

    public static MemberResponse from(Member member) {
        // ... (기존 날짜 변환 로직)

        return MemberResponse.builder()
                .id(member.getId())
                .username(member.getUsername())
                .nickname(member.getNickname())
                .email(member.getEmail())
                .status(member.getStatus())
                .role(member.getRole())
                .createDate(createDate)
                .modifyDate(member.getModifyDate())
                .lastLoginAt(lastLoginAtDate)
                .approvedAt(approvedAt)
                .apiKey(member.getApiKey()) // ✅ 매핑 추가
                .build();
    }
}

새 블로그 포스팅 초안을 작성해 드립니다. Spring Boot에서 JWT 인증 오류 해결 과정과 JSP PropertyNotFoundException 해결 경험을 통합하여 포트폴리오에 제출하기 좋도록 구성했습니다.

Spring Boot 웹 애플리케이션 개발 중 발생한 핵심 문제 해결 경험: JWT 인증과 JSP 데이터 바인딩 오류

💡 프로젝트 개요 및 당면 과제

Spring Boot와 Spring Security를 활용하여 회원 관리 시스템을 구축하는 과정에서 두 가지 주요 기술적 난관에 직면했습니다.

  • 첫 번째는 JWT(JSON Web Token) 기반 인증 시스템을 구현하던 중 발생한 UnsatisfiedDependencyException으로, UserDetailsService 빈(Bean)이 누락되어 애플리케이션 시작 자체가 불가능한 문제였습니다.
  • 두 번째는 사용자 프로필 페이지를 JSP로 개발하던 중 발생한 PropertyNotFoundException으로, DTO(Data Transfer Object)와 View 간의 데이터 불일치로 인해 런타임 오류가 발생했습니다.

이 포스팅에서는 이 두 가지 핵심 문제를 어떻게 분석하고 해결했는지 상세히 공유합니다.
이 과정에서 Spring Security의 JWT 인증 흐름, Spring의 빈 관리 메커니즘, 그리고 웹 프레젠테이션 계층에서의 데이터 바인딩 중요성에 대한 깊이 있는 이해를 얻을 수 있었습니다.


🎯 문제 해결 1: JWT 인증 UnsatisfiedDependencyException (UserDetailsService 빈 누락)

🚨 문제 상황

애플리케이션을 시작할 때 다음과 같은 에러 로그와 함께 서버가 구동되지 않았습니다.

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'jwtAuthenticationFilter': Unsatisfied dependency expressed through constructor parameter 0: Error creating bean with name 'jwtTokenProvider': Unsatisfied dependency expressed through constructor parameter 2: No qualifying bean of type 'org.springframework.security.core.userdetails.UserDetailsService' available
문제 원인: JwtTokenProvider 클래스가 UserDetailsService 타입의 빈을 주입받으려 했지만, Spring 컨테이너에 해당 빈이 정의되어 있지 않아 의존성 주입(Dependency Injection)에 실패한 것입니다. JwtTokenProvider는 JWT 토큰을 검증하고 인증 객체를 생성하는 과정에서 사용자 정보를 조회하기 위해 UserDetailsService를 필요로 합니다.
  • 🛠 해결 과정: UserDetailsService 구현 및 통합

    Spring Security에서 사용자 정보를 로드하는 핵심 인터페이스인 UserDetailsService를 기존 MemberApplicationService에 구현하여 문제를 해결했습니다.

  • UserDetailsService 인터페이스 구현

    MemberApplicationService가 UserDetailsService 인터페이스를 구현하고, loadUserByUsername 메서드를 오버라이드하도록 수정했습니다.

@Service
@Transactional(readOnly = true)
public class MemberApplicationService implements UserDetailsService {
    // ... (기존 필드 및 생성자)

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.debug("Loading user by username: {}", username);
        Member member = memberDomainService.findByUsername(username)
                .orElseThrow(() -> {
                    log.warn("User not found: {}", username);
                    return new UsernameNotFoundException("User not found with username: " + username);
                });

        // Member 엔티티를 Spring Security의 UserDetails 객체로 변환
        return User.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(member.getRole().name())
                .accountLocked(member.getStatus() == MemberStatus.SUSPENDED)
                .disabled(member.getStatus() != MemberStatus.APPROVED)
                .build();
    }

    // ... (기존 회원 관리 메서드들)
}

주요 변경 사항

  • UserDetailsService를 구현함으로써 MemberApplicationService 자체가 UserDetailsService 빈으로 등록될 수 있도록 했습니다.

  • loadUserByUsername 메서드 내에서 memberDomainService를 통해 사용자(Member)를 조회하고, 조회된 Member 객체를 Spring Security의 User 객체(implements UserDetails)로 변환하여 반환하도록 했습니다.
    이는 Spring Security가 사용자 인증 정보를 처리하는 데 필수적인 과정입니다.

대안적 고려 (단일 책임 원칙)

이 해결 방식 외에, UserDetailsService 구현을 위한 별도의 UserDetailsServiceImpl 클래스를 생성하여 책임을 분리하는 방법도 고려했습니다. 이는 단일 책임 원칙(SRP)을 준수하여 코드의 응집도를 높이고 유지보수를 용이하게 할 수 있습니다. 하지만 현재 프로젝트의 규모와 컨텍스트를 고려하여 MemberApplicationService에 통합하는 방식을 선택했습니다.

환경 확인 및 캐시 정리

코드 수정 후, 패키지 스캔이 정상적으로 이루어지는지 확인하고, 혹시 모를 의존성 캐시 문제를 해결하기 위해 Gradle clean build --refresh-dependencies 명령과 IntelliJ IDEA 캐시 무효화를 진행했습니다.

✅ 결과 및 학습

UserDetailsService를 성공적으로 구현하고 빈으로 등록함으로써 UnsatisfiedDependencyException을 해결하고 애플리케이션을 정상적으로 구동할 수 있었습니다.
이 과정을 통해 Spring Security에서 UserDetailsService가 사용자 인증 정보를 제공하는 핵심 컴포넌트이며, JWT 인증 흐름에서 사용자 정보를 조회하는 데 필수적인 역할을 한다는 것을 명확히 이해했습니다.
또한, Spring의 빈 관리와 의존성 주입 원리를 실질적으로 경험하며 에러 디버깅 능력을 향상시킬 수 있었습니다.

🎯 문제 해결 2: JSP PropertyNotFoundException (DTO와 View 데이터 불일치)

🚨 문제 상황

회원 프로필 페이지(members/profile.jsp)를 개발하던 중, 페이지 접근 시 다음과 같은 500 에러가 발생했습니다.

Property [apiKey] not found on type [com.antock.api.member.application.dto.response.MemberResponse]
jakarta.el.PropertyNotFoundException: Property [apiKey] not found on type [com.antock.api.member.application.dto.response.MemberResponse]
에러 발생 위치: JSP 템플릿의 특정 라인: <input type="text" class="form-control font-monospace" value="${member.apiKey}" readonly id="apiKey">
  • 근본 원인: JSP에서 ${member.apiKey}라는 표현식을 사용하여 MemberResponse DTO 객체의 apiKey 속성에 접근하려 했지만, 실제 MemberResponse DTO 클래스에는 apiKey 필드가 정의되어 있지 않아 발생한 오류입니다.

Spring Expression Language (EL)는 내부적으로 getApiKey()와 같은 getter 메서드를 호출하는데, 해당 메서드가 존재하지 않았던 것입니다.

🛠 해결 과정: MemberResponse DTO 수정

MemberResponse DTO 클래스에 누락된 apiKey 필드를 추가하고, from() 정적 팩토리 메서드에서 해당 필드를 올바르게 매핑하도록 수정하여 문제를 해결했습니다.

MemberResponse DTO 분석

초기 MemberResponse DTO는 다음과 같았습니다.

@Getter
@Builder
public class MemberResponse {
    private Long id;
    // ... (다른 필드들)
    // apiKey 필드 누락!
}

apiKey 필드 추가 및 매핑

  • MemberResponse DTO에 private String apiKey; 필드를 추가하고, from() 메서드에서 Member 엔티티의 getApiKey() 값을 매핑하도록 수정했습니다.
@Getter
@Builder
public class MemberResponse {
    private Long id;
    private String username;
    private String nickname;
    private String email;
    private MemberStatus status;
    private Role role;
    private Date createDate;
    private LocalDateTime modifyDate;
    private Date lastLoginAt;
    private Date approvedAt;
    private String apiKey; // ✅ 필드 추가

    public static MemberResponse from(Member member) {
        // ... (기존 날짜 변환 로직)

        return MemberResponse.builder()
                .id(member.getId())
                .username(member.getUsername())
                .nickname(member.getNickname())
                .email(member.getEmail())
                .status(member.getStatus())
                .role(member.getRole())
                .createDate(createDate)
                .modifyDate(member.getModifyDate())
                .lastLoginAt(lastLoginAtDate)
                .approvedAt(approvedAt)
                .apiKey(member.getApiKey()) // ✅ 매핑 추가
                .build();
    }
}

✅ 결과 및 학습

MemberResponse DTO에 apiKey 필드를 추가하고 올바르게 매핑함으로써 JSP에서 해당 속성을 성공적으로 참조할 수 있게 되어 PropertyNotFoundException 오류가 해결되었습니다.
이 경험을 통해 DTO와 View 간의 데이터 일치성의 중요성과 Spring EL의 동작 원리를 더욱 깊이 이해하게 되었습니다.
또한, 스택 트레이스에서 제공되는 에러 메시지를 정확히 파악하고, 관련된 데이터 계층의 설계를 검토하는 디버깅 역량을 강화할 수 있었습니다.


🚀 프로젝트의 다른 주요 성과

이러한 문제 해결 경험 외에도, 저는 Spring Boot와 JWT를 활용한 완전한 웹 인증 시스템을 구축하는 과정에서 다음과 같은 기술적 성과를 달성했습니다.

1. 이중 토큰 전략 및 보안 강화

  • Access Token (1시간)Refresh Token (24시간)을 활용한 이중 토큰 전략을 구현하여 보안성과 사용자 편의성을 동시에 확보했습니다.

  • HttpOnly 및 Secure 플래그를 포함한 쿠키 설정을 통해 XSS 공격을 방지하고 HTTPS 환경에서만 전송되도록 하여 보안을 강화했습니다.

  • HS512 알고리즘에 필요한 최소 512비트 길이의 안전한 JWT 시크릿 키 자동 생성 시스템을 구현하여 보안 요구사항을 충족시켰습니다.

2. Spring Security와의 통합 및 쿠키 기반 인증 필터

  • JwtAuthenticationFilter를 구현하여 Authorization 헤더와 accessToken 쿠키를 모두 지원하는 통합 인증 시스템을 구축했습니다.

  • Spring Security의 SecurityFilterChain을 커스터마이징하여 JWT 인증 필터를 UsernamePasswordAuthenticationFilter 이전에 추가함으로써 요청 처리 흐름에 JWT 인증 로직을 자연스럽게 통합했습니다.

  • 세션 관리를 STATELESS로 설정하여 JWT의 핵심 원칙을 따르고 서버의 확장성을 확보했습니다.

3. 성능 최적화 및 에러 처리

  • 메모리 캐싱 시스템을 구현하여 반복적인 DB 조회를 줄이고 성능을 향상시켰습니다.

  • Rate Limiting을 도입하여 API 남용 및 서비스 과부하를 방지했습니다.

  • @RestControllerAdvice를 활용한 글로벌 예외 처리 시스템을 구축하여 예측 불가능한 에러에 대한 안정적인 응답을 제공하고, 특정 에러에 대한 로깅 레벨을 조절하여 불필요한 로그를 줄였습니다.

  • 디버깅을 위한 DebugController를 구현하여 실시간 인증 상태, 쿠키 및 헤더 정보를 쉽게 확인할 수 있도록 했습니다.

4. 사용자 경험(UX) 고려

  • 쿠키 기반의 자동 로그인 유지 기능을 통해 브라우저 재시작 후에도 로그인 상태가 유지되도록 하여 seamless한 사용자 경험을 제공했습니다.

  • Bootstrap 5를 활용하여 반응형 웹 인터페이스를 구축하고, 로그인, 회원가입, 프로필 페이지 등을 포함한 기본적인 웹 흐름을 구현했습니다.

💡 결론 및 향후 계획

이번 Spring Boot 프로젝트를 통해 JWT 기반 인증 시스템 구축, Spring Security의 심층적인 이해, 그리고 DTO와 View 간의 데이터 연동 및 에러 처리 등 실제 프로덕션 환경에서 요구되는 종합적인 백엔드 개발 역량을 크게 향상시킬 수 있었습니다.
특히, 단순히 기능을 구현하는 것을 넘어 발생할 수 있는 잠재적 문제들을 예측하고, 효과적인 디버깅과 최적화를 통해 안정적인 시스템을 구축하는 과정을 경험했습니다.

앞으로 OAuth2 소셜 로그인 통합, 이메일 인증 시스템 추가 등 기능을 확장하고, 장기적으로는 마이크로서비스 아키텍처로의 전환과 Redis 세션 클러스터링, API Gateway 통합 등을 통해 시스템의 확장성과 안정성을 더욱 강화해 나갈 계획입니다.
이 프로젝트는 저의 기술적인 도전과 성공적인 문제 해결 능력을 보여주는 중요한 포트폴리오 사례가 될 것입니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글