Skip to content

beurre-noisette/Server

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

103 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CokeZet 제로콜라 최저가 알림 서비스

프로젝트 개요

CokeZet은 사용자가 선호하는 커머스에서 제로콜라 최저가 정보와 카드사 할인 혜택을 알려주는 모바일 서비스입니다. 매일 최저가를 찾아다니는 번거로움 없이, 원하는 조건의 할인 정보가 등록되면 알림을 받을 수 있습니다.

담당 영역

  • 소셜 로그인 시스템 : 플랫폼별 소셜 로그인 구현 (Android: Google/Kakao, iOS: Apple/Kakao)
  • 알림 기능 : 사용자 맞춤형 실시간 가격 알림 서비스

사용 기술 및 선택 이유

  • 언어 : Java 21

  • 프레임워크 : Spring Boot 3.4.3

  • ORM : Spring Data JPA/Hibernate

    • 테이블 중심의 모델링에 익숙해져 있었는데, 객체 중심적 아키텍쳐를 연습해보고자 JPA를 선택하였습니다.
    • JPA는 객체 간의 관계 표현과 도메인 모델링이 직관적이며, 반복적인 SQL 작성 없이 비즈니스 로직에 집중할 수 있다는 장점이 있다고 생각합니다.
  • 보안 : Spring Security, JWT

    • CokeZet는 네이티브 앱으로만 제공될 서비스입니다. 다음과 같은 이유로 세션 활용의 한계를 느꼈습니다.
      • 웹 브라우저와 달리 네이티브 앱은 쿠키를 자동으로 관리하지 않습니다. 따라서 세션을 사용할 경우 개발자가 직접 세션 ID 저장 및 요청 포함 로직을 구현해야 합니다.
      • 앱이 완전히 종료된 후에도 인증 상태를 유지해야 하는데, 이때 추가적인 로직이 필요합니다.
      • 추후 서비스의 규모가 커질 경우 세션 저장소 관리 부담이 증가할 것을 우려했습니다.
      • 모바일 네트워크 특성 상 불안정한 연결이나 높은 지연 시간 등의 문제가 있을 확률이 높다고 판단했으며, 이러한 모바일 네트워크 환경에서 세션 관리의 어려움이 우려됐습니다.
    • 따라서 다음과 같은 이유로 JWT를 세션의 대안으로 사용했습니다.
      • JWT의 Stateless 아키텍쳐 특성을 활용했습니다. 서버가 클라이언트의 상태를 저장할 필요가 없고 서버 측 세션 저장소가 불필요하며 토큰 자체에 저희가 요구할 최소한의 정보를 포함시킬 수 있다고 생각했습니다.
      • 추후 다중 서버 환경으로 확장을 고려했을 때 별도의 세션 공유 메커니즘 없이 인증을 할 수 있다고 판단했습니다.
      • 토큰을 저장소에 안전하게 보관하고 요청마다 헤더에 포함시키는 방식이 모바일 앱 아키텍쳐에 적합하다고 판단했습니다.
    • global/security/jwt/JwtProvider.java, global/security/jwt/JwtAuthenticationFilter.java에 관련 코드가 있습니다.
  • 데이터베이스 : MySQL

  • API 문서화 : SpringDoc OpenAPI 2.8.5 (Swagger UI)

    • 앱 개발자와의 원활한 협업을 위해 표준화되고 자동화된 문서 생성이 필요하다고 느껴서 사용하였습니다.
    • 백엔드 개발자로써 코드 변경 시 문서가 자동으로 업데이트되어 항상 최신 상태를 유지할 수 있다는 점이 좋았습니다.
  • OAuth : Srping OAuth2 Client, Resource Server

  • JWT 관련 라이브러리 :

    • io.jsonwebtoken:jjwt 0.11.5 : 기본인 JWT 생성 및 검증에 사용하였습니다.
    • Nimbus JOSE+JWT 9.37.3(for Apple Login) : Apple의 JWK 처리 및 ECDSA 서명 검증에 특화된 라이브러리여서 사용하였습니다.
    • Auth0 JWT 4.4.0(for Apple Login) : Apple Client Secret 생성 시 필요한 ECDSA 알고리즘을 지원하여 사용하였습니다.
    • domain/user/service/apple/AppleJwtKeyService.java, domain/user/service/apple/AppleClientSecretService.java에 관련 코드가 있습니다.

구현 기능 및 설계 결정 이유

소셜 로그인

플랫폼별 최적화된 소셜 로그인

  • 각 플랫폼의 사용자 경험을 최적화하기 위해 플랫폼별 선호 로그인 방식을 구현하고자 했습니다.
    • Andorid : Google(기기 연동이 용이) + Kakao(국내 사용자 친화)
    • Apple : Apple(Apple 정책 준수) + Kakao(국내 사용자 친화)
  • 단일 백엔드에서 모든 인증 방식을 일관되게 처리하기 위한 아키텍쳐를 고민했습니다.
    • domain/user/service/GoogleLoginService.java, domain/user/service/AppleLoginService.java에 관련 코드가 있습니다.

SocialLoginService 인터페이스와 같은 전략 패턴을 적용하였습니다.

  • 각 소셜 로그인 제공자의 구현 세부사항을 캡슐화하여 유지보수성을 향상시키고자 하였습니다.
  • 새로운 소셜 로그인 추가 시 기존 코드 변경 없이 확장 가능한 설계를 해보고자 고민하였습니다.
  • domain/user/service/SocialLoginService.java, domain/user/service/SocialLoginFactory.java에 관련 코드가 있습니다.

일관된 인증 API 응답 구조 설계

  • 모든 인증 API(소셜 로그인, 토큰 검증)가 일관된 LoginResponse 형식으로 응답하도록 구현했습니다.
  • 클라이언트 개발자가 단일한 방식으로 인증 응답을 처리할 수 있어 개발 복잡성이 감소합니다.
  • 모든 로그인 API에서 accessToken, refreshToken, 사용자 정보를 동일한 구조로 제공합니다.
  • domain/user/service/AuthService.java에 토큰 검증 및 갱신 로직을 중앙화하여 코드 중복을 방지했습니다.
// AuthService.java의 일부분
public LoginResponse validateAndRefreshToken(Long userId) {
  User user = userRepository.findById(userId)
          .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));

  // JWT 액세스 토큰 생성
  String accessToken = jwtProvider.generateAccessToken(user.getId(), user.getEmail(), user.getRole());

  // 리프레시 토큰 생성 (기존 토큰 삭제 후 새로 생성)
  RefreshToken refreshToken = refreshTokenService.createRefreshToken(user.getId());

  return LoginResponse.builder()
          .accessToken(accessToken)
          .refreshToken(refreshToken.getToken())
          .user(new LoginResponse.UserInfo(
                  user.getId(),
                  user.getEmail(),
                  user.getNickname()
          ))
          .newUser(false)
          .build();
}

모바일 자동 로그인 최적화

  • 모바일 앱의 사용자 경험을 고려한 자동 로그인 흐름을 설계했습니다.

    • 앱 재시작 시 GET /api/auth/login으로 저장된 토큰 검증 및 갱신
    • 토큰 만료 시 리프레시 토큰으로 자동 갱신 처리
    • 모든 토큰 만료 시에만 사용자에게 재로그인 요청
  • 매 로그인 시도마다 새로운 토큰을 발급하는 전략으로 보안을 강화하고자 했습니다.

    • 발급된 토큰의 사용 기간을 최소화하여 탈취 위험 감소
    • 토큰 만료 관리의 복잡성 대신 보안성 우선 접근
    • API 호출 패턴 분석을 통한 최적의 토큰 갱신 전략 수립

Refresh Token Rotation 정책을 취했습니다.

  • 토큰이 탈취되어도 한 번 사용 후에는 무효화되어 보안 위험을 최소화할 수 있다고 판단했습니다.
  • 또한 토큰 재사용 감지 시 모든 리프레시 토큰을 무효화하는 방식으로 추가 보안 조치를 취했습니다.
  • domain/user/service/RefreshTokenService.java, domain/user/model/RefreshToken.java에 관련 코드가 있습니다.

토큰 재사용 감지 로직을 구현했습니다.

  • 리프레쉬 토큰 탈취 시도를 실시간으로 감지하여 즉각적인 대응을 할 수 있도록 구현하였습니다.
  • 단순한 만료 처리가 아닌 사용 여부를 데이터베이스에 기록하여 추가적인 대응 여부를 결정할 수 있도록 처리하였습니다.
  • global/error/exception/TokenReuseDetectedException.java, domain/user/service/RefreshTokenService.java에 관련 코드가 있습니다.

기술적 도전과 해결 방법 및 이유

1. Apple 로그인 구현의 복잡성

문제 상황:

  • Apple의 OAuth 2.0 구현은 Google보다 훨씬 복잡한 검증 과정을 요구했습니다.
  • ID 토큰 검증을 위해 Apple의 공개키(JWK)를 가져와 ECDSA 서명을 검증해야 했습니다.
  • Apple 서버와의 통신 빈도가 높아질 경우 API 호출 제한에 도달할 위험이 있었습니다.
  • 키 관리와 토큰 검증 로직의 복잡도가 높았습니다.

해결 방법:

  • JWK 캐싱 메커니즘 구현: AppleJwtKeyService 클래스에서 Apple 공개키를 가져와 메모리에 캐싱하는 로직을 구현했습니다.
// AppleJwtKeyService.java의 일부분
private void fetchAndCacheApplePublicKeys() {
    try {
        String response = restTemplate.getForObject(APPLE_PUBLIC_KEYS_URL, String.class);
        JWKSet jwkSet = JWKSet.parse(response);
        for (JWK jwk : jwkSet.getKeys()) {
            publicKeys.put(jwk.getKeyID(), jwk);
        }
        log.info("Apple 공개 키 캐싱 완료: {} 개의 키", publicKeys.size());
    } catch (Exception e) {
        log.error("Apple 공개 키 가져오기 실패", e);
        throw new RuntimeException("Apple 공개키를 가져오는 중 오류가 발생했습니다.", e);
    }
}
  • 토큰 검증 로직 최적화: Nimbus JOSE+JWT 라이브러리를 사용해 ID 토큰의 헤더에서 kid(Key ID)를 추출하고, 캐싱된 키를 사용해 서명을 검증하는 방식으로 구현했습니다.
// AppleLoginService.java의 일부분
SignedJWT signedJWT = SignedJWT.parse(idToken);
String kid = signedJWT.getHeader().getKeyID();
JWK jwk = appleJwtKeyService.getPublicKey(kid);
boolean verified = signedJWT.verify(new ECDSAVerifier(jwk.toECKey()));
  • 예외 처리 강화: 토큰 검증 실패, 키 미존재 등 다양한 예외 상황에 대비한 세분화된 예외 처리를 구현했습니다.

성과:

  • Apple 서버 호출을 최소화하여 API 제한에 걸릴 위험을 낮추었습니다.
  • 토큰 검증 속도를 향상시켜 로그인 응답 시간을 단축했습니다.
  • 키 관리의 복잡성을 캡슐화하여 코드 가독성과 유지보수성을 향상시켰습니다.

2. JWT 토큰 보안 강화 과제

문제 상황:

  • JWT는 발급 후 만료되기 전까지 유효하여, 토큰이 탈취될 경우 보안 위험이 크다고 생각합니다.
  • Access Token의 유효 기간을 짧게 설정하면 사용자 경험이 저하되고, 길게 설정하면 보안 위험이 증가하는 딜레마가 있었습니다.
  • Refresh Token의 장기 보관에 따른 보안 위험도 고려해야 했습니다.

해결 방법:

  • Refresh Token Rotation 구현: 리프레시 토큰을 한 번만 사용할 수 있게 하고, 사용 시 새로운 리프레시 토큰을 발급하는 방식을 구현했습니다.
// RefreshTokenService.java의 일부분
@Transactional
public RefreshTokenResponse refreshAccessToken(String token) {
    // 토큰 검증
    RefreshToken refreshToken = refreshTokenRepository.findByToken(token)
            .orElseThrow(() -> new RefreshTokenNotFoundException("존재하지 않는 Refresh Token 입니다."));

    // 사용 여부 확인 (재사용 감지)
    if (refreshToken.isUsed()) {
        // 보안 침해 가능성 : 모든 토큰 무효화
        refreshTokenRepository.deleteByUserId(refreshToken.getUserId());
        throw new TokenReuseDetectedException("토큰 재사용이 감지되었습니다. 보안을 위해 재로그인이 필요합니다.");
    }

    // 기존 토큰 사용 처리
    refreshToken.setUsed(true);
    refreshTokenRepository.save(refreshToken);

    // 새 리프레시 토큰 생성 (Rotation)
    RefreshToken newRefreshToken = createRefreshToken(refreshToken.getUserId());
    // 새 액세스 토큰 생성...
}
  • 토큰 재사용 감지 메커니즘: 이미 사용된 리프레시 토큰이 다시 사용되는 경우, 토큰 탈취 상황으로 판단하고 해당 사용자의 모든 리프레시 토큰을 무효화하는 로직을 구현했습니다. Access Token과 Refresh Token의 유효 기간 최적화: Access Token은 30분으로 짧게, Refresh Token은 30일로 설정하여 보안과 사용성의 균형을 맞추었습니다.
// application-jwt.yml
jwt:
  access-token-validity-in-seconds: 1800  # 30
  refresh-token-validity-in-seconds: 2592000  # 30

성과:

  • 토큰 탈취 상황에서도 공격자가 토큰을 재사용할 수 없게 하여 보안을 강화했습니다.
  • 토큰 재사용 감지 시 즉각적인 대응을 통해 추가 피해를 방지할 수 있게 되었습니다.
  • Access Token의 짧은 유효 기간으로 보안을 강화하면서도, Refresh Token Rotation을 통해 사용자 경험을 해치지 않는 방식을 구현했습니다.

3. 확장 가능한 아키텍처 설계 도전

문제 상황:

  • 여러 소셜 로그인 제공자(Google, Apple, Kakao)의 상이한 인증 방식을 일관되게 처리해야 했습니다.
  • 새로운 소셜 로그인이 추가되거나 기존 API가 변경될 때마다 전체 시스템에 영향을 주지 않는 구조가 필요했습니다.
  • 각 소셜 로그인의 세부 구현은 다르지만, 백엔드에서는 동일한 방식으로 인증 정보를 처리해야 했습니다.

해결 방법:

  • 전략 패턴과 팩토리 패턴 결합: SocialLoginService 인터페이스를 정의하고 각 소셜 로그인 제공자별로 구현체를 만들었으며, SocialLoginFactory를 통해 적절한 구현체를 선택하는 방식을 구현했습니다.
// SocialLoginService.java
public interface SocialLoginService {
    LoginResponse login(String idToken);
    SocialProvider getSocialProvider();
}

// SocialLoginFactory.java의 일부분
@Component
@RequiredArgsConstructor
public class SocialLoginFactory {
    private final Set<SocialLoginService> loginServices;

    public SocialLoginService getLoginService(SocialProvider socialProvider) {
        return loginServices.stream()
                .filter(service -> service.getSocialProvider() == socialProvider)
                .findFirst()
                .orElseThrow(() -> new UnsupportedSocialTypeException(
                        "지원하지 않는 소셜 로그인입니다: " + socialProvider));
    }
}
  • 의존성 주입과 컴포넌트 스캔 활용: Spring의 의존성 주입 기능을 활용해 소셜 로그인 서비스를 자동으로 등록하고 필요한 곳에서 주입받아 사용하는 구조를 구현했습니다.
  • 추상화 계층 도입: 소셜 로그인의 세부 구현 차이를 추상화하여 컨트롤러 계층에서는 단일 인터페이스로 접근할 수 있게 했습니다.
// AuthRestController.java의 일부분
@PostMapping("/login")
public ResponseEntity<ApiResult<LoginResponse>> socialLogin(@RequestBody SocialLoginRequest request) {
    SocialLoginService loginService = socialLoginFactory.getLoginService(request.getProvider());
    LoginResponse response = loginService.login(request.getIdToken());
    return ResponseEntity.ok(ApiResult.success(response));
}

성과:

  • 새로운 소셜 로그인 제공자 추가 시 기존 코드 변경 없이 새로운 구현체만 추가하면 되는 확장성을 확보했습니다.
  • 각 소셜 로그인의 API 변경 시 해당 구현체만 수정하면 되어 유지보수성이 향상되었습니다.
  • 컨트롤러와 서비스 계층의 결합도를 낮추어 테스트 용이성을 높였습니다.
  • Spring의 기능을 최대한 활용하여 구현 복잡도를 낮추고 코드 가독성을 높였습니다.

4. 모바일 앱을 위한 인증 흐름 최적화

문제 상황:

  • 모바일 앱에서는 토큰 기반 인증 시 다양한 상태(토큰 유효, 만료, 재발급 등)를 처리해야 했습니다.
  • 앱 개발자와 백엔드 개발자 간 API 응답 형식에 대한 이해 차이로 통합 이슈가 발생했습니다.
  • 보안과 사용자 경험 사이의 균형을 맞추는 토큰 관리 전략이 필요했습니다.

해결 방법:

  • 인증 API 응답 통일: 모든 인증 관련 API가 일관된 LoginResponse 형식을 반환하도록 설계했습니다.
// AuthRestController.java의 일부분
@GetMapping("/login")
public ResponseEntity<ApiResult<LoginResponse>> validateToken(@AuthenticationPrincipal UserPrincipal principal) {
    LoginResponse loginResponse = authService.validateAndRefreshToken(principal.getId());
    return ResponseEntity.ok(ApiResult.success(loginResponse));
}
  • AuthService 도입: 인증 관련 로직을 중앙화하여 코드 중복을 제거하고 일관된 인증 처리를 구현했습니다.
  • API 문서화(Markdown(Mermaid)): 각 인증 API의 목적과 사용 시나리오를 명확히 설명하여 앱 개발자의 이해를 돕는 문서를 작성했습니다.

성과:

  • 앱과 백엔드 간 원활한 통합으로 개발 속도가 향상되었습니다.
  • 사용자에게 투명한 인증 경험을 제공하면서도 보안을 유지하는 균형을 유지할 수 있다고 생각합니다.
  • 다양한 인증 상태에 대한 명확한 처리 흐름을 구축하여 앱의 안정성이 향상될 것으로 기대하고 있습니다.

이러한 기술적 도전들을 해결하면서, 보안성과 확장성을 모두 고려한 견고한 백엔드 시스템을 구축하고자 노력하고 있습니다. 특히 모바일 앱 특성에 맞는 인증 시스템 구현과 사용자 경험을 해치지 않는 보안 강화 방안을 모색하는 과정이 가장 큰 학습 포인트였습니다.

향후 개발 계획과 이유

FCM 푸시 알림 구현

  • 서비스의 핵심 가치인 "최저가 알림"을 제공하기 위해 필수적으로 구현해야 합니다.
  • 푸시 알림을 통해 사용자는 앱을 실행하지 않아도 중요 정보를 받아볼 수 있습니다.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors