코드는 ahnjehoon/spring-boot3-tutorial (github.com) 에 올라가 있습니다

 

JPA와 연동해 로그인을 진행해보겠습니다

 


개발환경

  • JDK 17
  • intellij 2023.3.6 (Community Edition)
  • Spring Boot 3.2.4
  • spring-boot-starter-security 3.2.4
  • spring-boot-starter-oauth2-resource-server 3.2.4

간단한 예외 처리

spring에서 예외가 발생하면 /error 엔드포인트로 리다이렉트됩니다

spring security 설정시 spring security filter가 동작하기 때문에,

권한이 없는 사용자의 경우 401 Unauthorized 응답만 받게 됩니다

이를 해결하기 위한 방법은 두 가지입니다:

  1. Spring Security 설정에서 /error 엔드포인트에 대한 접근 권한을 permitAll()로 열어줍니다.
  2. @RestControllerAdvice를 사용하여 직접 예외 처리 핸들러를 작성하고, 에러 응답을 반환합니다.

1의 경우 단순히 에러코드만 반환하기때문에 로그를 보지 않고는 확인이 어렵습니다.

2의 경우 에러 메세지를 반환하여 예외 메세지 처리를 해두었다면 어디서 문제가 발생했는지 확인이 가능합니다.

2방법으로 간단하게 처리하고 넘어가겠습니다.

config/ExceptionConfig.java

@RestControllerAdvice
public class ExceptionConfig {
    @ExceptionHandler(Exception.class)
    protected final ResponseEntity<String> handleAllException(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getMessage());
    }
}

비밀번호 암호화 BCryptPasswordEncoder

비밀번호를 저장할때는 평문으로 저장하면 안됩니다

spring security에서는 평문을 암호화 해주는 기능을 제공합니다

기본값으로 blowfish 알고리즘을 이용한 bcrypt 암호화 방식이 설정되어 있습니다

SecurityConfig에 passwordEncoder를 추가할때 설명이 들어갔어야 했는데 누락됐네요..
간단하게 정리해보겠습니다

  1. 랜덤 salt 생성
  2. salt + 평문 조합 (EksBlowfishKey)
  3. 조합된 값으로 암호화 함수 반복 실행 (ExpensiveKey)
  4. 결과도출 = 버전 + 반복 횟수 + 반복 실행 결과 값 + 암호화에 사용한 사용한 salt값

계산 비용이 높기때문에 공격자가 무차별대입공격시 계산 자원이 많이 필요하게 만드는 방식입니다
서버도 마찬가지로 암호를 검증할때 많은 자원을 소모하는 단점이 있습니다


사용자 테이블

entity/User.java

@Entity
@Getter
@Setter
@Table(name = "users")
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    private String id;
    private String password;
    private String name;
    private String email;
}

 

repository/UserRepository.java

public interface UserRepository extends JpaRepository<User, String> {
    User findByIdAndPassword(String id, String password);
}

 

service/UserService.java

bcrypt는 복호화가 불가능하고 salt값을 매번 새롭게 만들기 때문에 검색조건에 추가할 수 없습니다

사용자 정보를 먼저 가져와서 암호화된 값에 저장된 salt값으로 비교해야합니다

 

'사용자 정보를 찾을 수 없다'는 메세지와 '비밀번호가 일치하지 않는다'는 메세지는 실무에서는 사용하지 마세요

실무에서는 '일치하는 사용자 정보를 찾을 수 없다'고만 알려주세요

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public User select(LoginRequest param) {
        var user = userRepository.findById(param.getId())
                .orElseThrow(() -> new RuntimeException("사용자 정보를 찾을 수 없습니다"));

        if (!passwordEncoder.matches(param.getPassword(), user.getPassword())) {
            throw new RuntimeException("패스워드가 일치하지 않습니다");
        }
        return user;
    }
}

 

dto/LoginRequest.java

@Getter
@Validated
public class LoginRequest {
    @NotEmpty
    private String id;
    @NotEmpty
    private String password;
}

 

controller/AuthController.java

jwt에서 claim 데이터를 추출하기 위한 방식 2가지로

/api/auth/token-info 에서는 jwt를 decode 해서 추출하고

/api/auth/token-info2에서는 SecurityContextHolder에서 추출해봤습니다

@RestController
@RequestMapping("auth")
@RequiredArgsConstructor
public class AuthController {
    private final JwtEncoder jwtEncoder;
    private final JwtDecoder jwtDecoder;
    private final UserService userService;

    @PostMapping("login")
    public String login(@RequestBody LoginRequest request) {
        final var user = userService.select(request);
        return createToken(user);
    }

    @GetMapping("token-info")
    public String decode(@RequestParam("token") String token) {
        final var jwt = jwtDecoder.decode(token);
        final var subject = jwt.getSubject();
        final var name = jwt.getClaim("name");
        final var email = jwt.getClaim("email");
        return String.format("subject: %s, name: %s, email: %s", subject, name, email);
    }

    @GetMapping("token-info2")
    public String decode() {
        final var authentication = SecurityContextHolder.getContext().getAuthentication();
        final var token = ((JwtAuthenticationToken) authentication).getToken();
        return String.format("subject: %s, name: %s, email: %s",
                token.getSubject(),
                token.getClaim("name"),
                token.getClaim("email")
        );
    }

    private String createToken(User user) {
        final var jwtClaimsSet = JwtClaimsSet.builder()
                .issuer("middle-developer.tistory.com")
                .issuedAt(Instant.now())
                .subject(user.getId())
                .expiresAt(Instant.now().plusSeconds(60 * 60 * 24))
                .claim("name", StringUtils.isEmpty(user.getName()) ? "" : user.getName())
                .claim("email", StringUtils.isEmpty(user.getEmail()) ? "" : user.getEmail())
                .build();

        return jwtEncoder.
                encode(JwtEncoderParameters.from(jwtClaimsSet))
                .getTokenValue();
    }
}

데이터 초기화

테스트 코드를 작성하거나 사용자 정보가 없어서 로그인이 불가능한 경우처럼 초기데이터가 필요할때가 있습니다

jpa.hibernate.ddl-auto=create일때 sql.init.data-locations에서 sql 파일을 읽어 처리하는 방법과

서버가 시작될 때 실행되는 특별한 인터페이스인 CommandLineRunner를 구현하는 방법이 있습니다

CommandLineRunner를 구현하는 방법으로 초기화를 진행해보겠습니다

config/DataInitializer.java

@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public void run(String... args) throws Exception {
        if (userRepository.count() > 0) {
            return;
        }
        initUser();
    }

    private void initUser() {
        var users = new ArrayList<User>();

        users.add(User.builder()
                .id("admin")
                .password(passwordEncoder.encode("P@ssw0rd"))
                .name("관리자")
                .build());

        users.add(User.builder()
                .id("user")
                .password(passwordEncoder.encode("P@ssw0rd"))
                .name("사용자")
                .build());

        userRepository.saveAll(users);
    }
}

 


서버 재시작할때마다 인증서가 초기화 되니 401에러가 발생된다면

swagger에서 Authorize 값을 넣어주지 않았나 확인해보세요 ^^

 

다음은 마지막으로 배포를 진행해보겠습니다

QueryDsl

현재 Sensor API는 검색조건이 전혀 없습니다.

검색조건을 추가하기 위해 QueryDsl을 사용하겠습니다.

JPQL이나 JPA Criteria를 사용하면 검색 조건을 추가할 수 있지만, 이 방식들에는 한계가 있습니다.

JPQL과 JPA Criteria는 문자열 기반의 쿼리 작성 방식을 사용합니다. 이로 인해 쿼리를 작성하는 데 번거로움이 있으며, 오류를 발견하기가 어렵습니다. 또한 복잡한 쿼리를 작성해야 하는 경우 유지보수가 어려워질 수 있습니다.

 

반면, QueryDsl은 이러한 JPQL과 JPA Criteria의 단점을 보완할 수 있습니다

QueryDsl은 타입 기반의 쿼리 작성 방식을 사용하므로, 컴파일 시점에 오류를 발견할 수 있습니다.

또한 동적 쿼리 생성을 위한 풍부한 API를 제공하여, 유연하고 가독성 높은 쿼리 작성이 가능합니다.

하지만 집계내거나 복잡한 쿼리에서는 한계가 있으니 상황에 맞춰 사용해주세요

 

개발 환경

  • Spring Boot 3.2.4
  • Spring Data Jpa 3.2.4
  • QueryDsl 5.1

환경 설정

intellij

File > Settings > Build, Execution, Deployment > Build Tools > Gradle

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.jehoon'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // Swagger
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'

    runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc'
    runtimeOnly 'com.mysql:mysql-connector-j'

    // QueryDSL
    implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
    annotationProcessor 'com.querydsl:querydsl-apt:5.1.0:jakarta'
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
}

tasks.named('test') {
    useJUnitPlatform()
}

// Querydsl 설정 시작
def generated = 'src/main/generated'

tasks.withType(JavaCompile).configureEach {
    options.generatedSourceOutputDirectory = file(generated)
}

sourceSets {
    main.java.srcDirs += "$projectDir/$generated"
}

clean {
    delete file('src/main/generated')
}
// Querydsl 설정 끝

 

위 설정을 마치셨으면 ./gradlew build 시 generated 폴더가 생성됩니다

여기에 QueryDsl이 만든 도메인 특화 언어(DSL: Domain Specific Language) 파일인 QClass 들이 생성됩니다.

 

entity.Sensor.java

변경된건 없지만 이전글을 안보는 경우도 있을 것 같아 넣어뒀습니다

@Entity
@Getter
@Setter
@Table(name = "sensors")
public class Sensor {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String code;
    private String name;
    private String type; // 온도, 습도, 전력, 움직임 등
}

config.QuerydslConfig.java

@Configuration
public class QuerydslConfig {
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

dto.SensorSelectRequest.java

id, code, type은 여러개를 받을 수 있도록 배열로 선언하고

이름은 일부 포함하는 like 문을 사용하기 위해 아래처럼 구성했습니다

@Getter
@Setter
public class SensorSelectRequest {
    private Long[] id;
    private String[] code;
    private String[] type;
    private String likeName;
}

 

expression.SensorExpression.java

검색조건을 위한 클래스생성은 안해도 되지만, 재사용성을 위해서 만들어 놓고 쓰는걸 추천드립니다

@RequiredArgsConstructor
public class SensorExpression {
    private final QSensor entity;

    public BooleanExpression inId(Long... param) {
        return param == null ? null : entity.id.in(param);
    }

    public BooleanExpression inCode(String... param) {
        return param == null ? null : entity.code.in(param);
    }

    public BooleanExpression inType(String... param) {
        return param == null ? null : entity.type.in(param);
    }

    public BooleanExpression likeName(String param) {
    	// import org.apache.commons.lang3.StringUtils;
        return StringUtils.isEmpty(param) ? null : entity.name.contains(param);
    }
}

 

repository.SensorQueryRepository.java

@Repository
@RequiredArgsConstructor
public class SensorQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    public List<Sensor> select(SensorSelectRequest param) {
        var sensor = QSensor.sensor;
        var sensorQueryExpression = new SensorExpression(sensor);
        return jpaQueryFactory
                .selectFrom(sensor)
                .where(
                        sensorQueryExpression.inId(param.getId()),
                        sensorQueryExpression.inCode(param.getCode()),
                        sensorQueryExpression.inType(param.getType()),
                        sensorQueryExpression.likeName(param.getLikeName())
                )
                .fetch();
    }
}

 

service.SensorService.java

@Service
@Transactional
@RequiredArgsConstructor
public class SensorService {

    private final SensorRepository sensorRepository;
    private final SensorQueryRepository sensorQueryRepository;

    public List<Sensor> select(SensorSelectRequest param) {
        return sensorQueryRepository.select(param);
    }

    public Sensor create(Sensor param) {
        return sensorRepository.save(param);
    }

    public Sensor update(Sensor param) {
        return sensorRepository.save(param);
    }

    public boolean delete(Long param) {
        sensorRepository.deleteById(param);
        return true;
    }
}

 

controller.SensorController.java

@RestController
@RequestMapping("api/sensor")
@RequiredArgsConstructor
public class SensorController {

    private final SensorService sensorService;

    @GetMapping
    public List<Sensor> select(@ParameterObject SensorSelectRequest param) {
        return sensorService.select(param);
    }

    @PostMapping
    public Sensor create(@RequestBody Sensor param) {
        return sensorService.create(param);
    }

    @PutMapping
    public Sensor update(@RequestBody Sensor param) {
        return sensorService.update(param);
    }

    @DeleteMapping("{id}")
    public boolean delete(@PathVariable("id") Long param) {
        return sensorService.delete(param);
    }
}

 

확인

 

검색조건이 동작하는것을 확인할 수 있습니다

 

코드는 GitHub - ahnjehoon/spring-boot3-tutorial 여기서 확인 가능합니다

'JAVA > SPRING' 카테고리의 다른 글

Spring Boot 3 Tutorial - 5 Security(2)  (0) 2024.04.16
Spring Boot 3 Tutorial - 5 Security(1)  (2) 2024.04.12
Spring Boot 3 Tutorial - 3 DBMS switch  (0) 2024.04.04
Spring Boot 3 Tutorial - 2 CRUD  (0) 2024.04.04
Spring Boot 3 Tutorial - 1 Hello World  (0) 2024.04.04

+ Recent posts