Web/Spring 2020. 3. 1. 01:34

 

오늘 다루어볼 내용은 테스트 코드에서 DB관련된 테스트를 작성하는 방법이다. 사실 지금까지 여러가지 유닛테스트를 짜면서 로직에 대한 검증을 하긴 했지만, 데이터베이스와 관련된 테스트 작성은 조금 꺼려하기는 했다. 이유는 여러가지이지만, 귀찮은 설정들이 필요하고 사실 외부 환경에 따라 테스트가 실패할 가능성도 있기 때문이다. 하지만 계속 미룰수는 없는 법.. 오늘은 간단하게 데이터베이스관련 된 테스트를 Junit으로 만들어볼 것이다.

 

환경

  • springboot
  • mongodb
  • junit

 

다루어볼 예제는 정말 간단한 User 객체에 대한 생성,수정,조회 등을 테스트해볼 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
buildscript {
    ext {
        flapdoodleVersion = "2.2.0"
    }
}
 
plugins {
    id 'org.springframework.boot' version '2.2.5.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id 'java'
}
 
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '13'
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation "de.flapdoodle.embed:de.flapdoodle.embed.mongo:${flapdoodleVersion}"
-> 임베디드 몽고 의존
}
 
test {
    useJUnitPlatform()
}
 
cs

 

build.gradle 내용이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
public class User {
 
    @JsonIgnore
    private ObjectId id;
    @NotNull
    private String username;
    @NotNull
    private Integer age;
    @NotNull
    private Address address;
    private String hobby;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class Address {
        @NotNull
        private String si;
        @NotNull
        private String gu;
        @NotNull
        private String dong;
    }
}
cs

 

간단한 유저 도메인 객체이다.

 

1
2
3
4
5
public interface UserRepository {
    Mono<User> findUserById(final ObjectId id);
    Mono<User> updateUser(final User user);
    Mono<User> createUser(final User user);
}
cs

 

해당 인터페이스는 DB Access 구현 방식에 구애 받지 않고 core한 영역에서 사용하기 위한 User Data Access 인터페이스이다. 나머지 코드를 보면 왜 구현 코드에 구애 받지 않는지 알 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Data
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
@Document("User")
public class UserData {
 
    @Id
    private ObjectId id;
    private String name;
    private Integer age;
    private AddressData userAddress;
    private String hobby;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class AddressData {
        private String si;
        private String gu;
        private String dong;
 
        public User.Address from() {
            return User.Address.of(
                    si,
                    gu,
                    dong
            );
        }
 
        public static UserData.AddressData to(User.Address address) {
            return UserData.AddressData.of(
                    address.getSi(),
                    address.getGu(),
                    address.getDong()
            );
        }
    }
 
    public User from() {
        return User.of(
                id,
                name,
                age,
                userAddress.from(),
                hobby
        );
    }
 
    public static UserData to(User user) {
        return UserData.of(
                user.getId(),
                user.getUsername(),
                user.getAge(),
                AddressData.to(user.getAddress()),
                user.getHobby()
        );
    }
}
cs

 

실제 DB Access를 위한 엔티티 클래스이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Repository
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {
 
    private final UserMongoRepository repository;
 
    @Override
    public Mono<User> findUserById(final ObjectId id) {
        return repository.findById(id).map(UserData::from);
    }
 
    @Override
    public Mono<User> updateUser(final User user) {
        return repository.save(UserData.to(user)).map(UserData::from);
    }
 
    @Override
    public Mono<User> createUser(final User user) {
        return repository.save(UserData.to(user)).map(UserData::from);
    }
 
}
cs

 

실제 DB Access 구현체를 필드에 가지고 있는 UserRepository의 구현체이다. 즉, core(프레임워크에 영향을 받지 않는 비지니스 영역)한 영역에서 UserRepository를 사용할때는 구현체에 대한 자세한 정보가 필요하지 않고 DI해서 사용하면 되는 것이다. 

 

1
public interface UserMongoRepository extends ReactiveMongoRepository<UserData, ObjectId> { }
cs

 

UserRepositoryImpl가 가지고 있는 DB Access Object 구현체이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableReactiveMongoRepositories(basePackages = {"com.example.test.data.db.repositories"}, reactiveMongoTemplateRef = "userMongoTemplate")
public class MongoConfig {
 
    @Bean
    public MongoClient mongoClient() {
        return MongoClients.create("mongodb://localhost:27017");
    }
 
    @Bean
    public ReactiveMongoTemplate userMongoTemplate(MongoClient mongoClient) {
        return new ReactiveMongoTemplate(mongoClient, "USER_DB");
    }
 
}
cs

 

몽고디비 설정이다. 여기까지 실제 동작하는 코드 영역의 클래스들이다. 이러한 클래스들이 잘 동작하길 원할 것이고, 실제 테스트를 해볼 것이지만 효율적인 테스트 방법이 무엇일까? 관련 API를 만들어서 직접 호출해본다? 과연 좋은 테스트 방법인지는 모르겠다. 이러한 DB 관련 테스트 코드를 짜보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@TestConfiguration
@EnableReactiveMongoRepositories(basePackages = {
        "com.example.test.data.db.repositories"
}, reactiveMongoTemplateRef = "userTestMongoTemplate")
public class TestEmbeddedMongoConfig {
 
    @Bean(initMethod = "start", destroyMethod = "stop")
    public MongodExecutable mongodExecutable() throws IOException {
        MongodStarter starter = MongodStarter.getDefaultInstance();
 
        IMongodConfig mongoDConfig = new MongodConfigBuilder()
                .version(Version.Main.V3_4)
                .net(new Net("127.0.0.1"27017false))
                .build();
 
        return starter.prepare(mongoDConfig);
    }
 
    @Bean("userTestMongoTemplate")
    public ReactiveMongoTemplate mongoTemplate() throws IOException {
        MongoClient mongo = MongoClients.create("mongodb://localhost:27017");
        return new ReactiveMongoTemplate(mongo, "USER_TEST_DB");
    }
}
cs

 

해당 설정은 테스트 코드에서만 사용하기 위한 설정이다. 몽고디비는 임베디드 몽고를 사용할 것이다. 필자로 사실 내부적으로 정확히 어떻게 동작하는지는 자세히 보지 않았지만, 실제 특정 포트를 물고 프로세스로 뜬다. 그래서 직접 포트 번호, ip를 기입해준다. 설정들이 다양하지만 간단히 예제를 다루어보는 것임으로 간단하게 설정을 마친다.

 

다음은 실제 테스트 코드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@ContextConfiguration(
        initializers = ConfigFileApplicationContextInitializer.class,
        classes = {
                TestEmbeddedMongoConfig.class,
                UserRepositoryImpl.class
        })
@ExtendWith(SpringExtension.class)
public class UserRepositoryTest {
 
    @Autowired
    private UserRepository userRepository;
 
    private User initData;
 
    @BeforeEach
    public void setup() {
        initData = userRepository.createUser(
                User.of(
                        new ObjectId(),
                        "yeoseonggae",
                        29,
                        User.Address.of("seoul""kangbuk""mia"),
                        "none"
                )
        ).block();
    }
 
    @Test
    @DisplayName("유저 데이터 생성 테스트")
    public void createUserTest() {
        //준비
        User createUser = User.of(
                new ObjectId(),
                "UserName",
                29,
                User.Address.of("seoul""kangbuk""mia"),
                "none"
        );
 
        //실행
        Mono<User> createdUser = userRepository.createUser(createUser);
 
        //단언
        StepVerifier.create(createdUser)
                .assertNext(user -> {
                    assertEquals(user.getUsername(), "UserName");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
 
    @Test
    @DisplayName("유저 데이터 수정 테스트")
    public void updateUserTest() {
        //준비
        initData.setUsername("여성게");
 
        //실행
        Mono<User> updatedUser = userRepository.updateUser(initData);
 
        //단언
        StepVerifier.create(updatedUser)
                .assertNext(user -> {
                    assertEquals(user.getId().toHexString(), initData.getId().toHexString());
                    assertEquals(user.getUsername(), "여성게");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
 
    @Test
    @DisplayName("유저 이름으로 유저 데이터 조회 테스트")
    public void findUserByNameTest() {
        //준비
        final ObjectId id = initData.getId();
 
        //실행
        Mono<User> findUser = userRepository.findUserById(id);
 
        //단언
        StepVerifier.create(findUser)
                .assertNext(user -> {
                    assertEquals(user.getUsername(), "yeoseonggae");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
}
cs

 

바로 위에 있는 테스트 몽고 설정을 import 한다. 그리고 우리는 UserRepository를 테스트 할것임으로 모든 빈들을 가져올 필요가 없이 테스트에 필요한 빈만 주입받아서 테스트 코드를 작성한다. 과연 결과는 어떻게 될까?(위 코드에 조금더 추가하여 실제로 데이터가 어떻게 왔다갔다하는지 콘솔로 찍었다.)

 

우선 테스트 성공 여부이다.

 

 

크아 깔끔하게 테스트를 통과하였다. 그렇다면 진짜 데이터가 왔다갔다 한것이 맞을까? 콘솔로 찍힌 로그를 확인해보자 !

 

 

유저 데이터 수정 테스트 로그이다.

 

 

유저 데이터 생성 테스트 로그이다.

 

 

마지막으로 조회 테스트 로그이다. 실제로 데이터가 잘 찍혀있다. 여기까지 간단히 몽고디비를 이용한 DB Access unit 테스트를 다루어보았다. 정말 간단한 테스트이지만, 이 예제를 잘 이용하면 실제 서비스 클래스 영역까지 사용되어서 정말 내가 개발한 클래스가 정상 작동하는지 확인해볼 수 있다.

 

혹시 코드가 필요하다면 아래 깃헙를 참고하자.

 

 

yoonyeoseong/dbTest

Contribute to yoonyeoseong/dbTest development by creating an account on GitHub.

github.com

posted by 여성게
:
Web/Spring 2020. 2. 20. 00:46

 

오늘 다루어볼 내용은 Webflux에서 사용되는 WebClient가 onCancel이 호출되는 시점이다.  이번에 애플리케이션을 개발하면서 많은 서비스가 통신을 하는데, 필자가 개발한 애플리케이션의 jaeger 로그를 보니 WebClient가 cancelled("The subscription was cancelled") 되었다는 로그가 찍혀있었다. 무슨 이유로 이러한 로그가 남는지 확인했더니, 아래와 같은 이유였다.

 

"AServer -> BServer ->CServer"

 

이렇게 3개의 서버가 통신하는 상황인데, AServer의 Client는 ReadTimeOut이 3초이고, BServer는 ReadTimeOut이 5초이다. 그런데 CServer가 응답을 주는데 4초가 걸렸다면? 

 

이런 상황에서 발생하는 것이 cancelled 상황이다. AServer는 이미 Readtimeout이 나버려서 500 에러가 발생하였고, 이제 더이상 BServer는 응답을 받아올 필요가 없어졌으므로 subscribe를 cancel을 해버리는 것이다. 간단히 위와 같은 상황을 샘플 앱으로 작성해 보았다.

 

[AServer:9090]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Configuration
public class WebClientConfig {
 
    @Bean
    public WebClient.Builder webClientFactory(){
        TcpClient tcpClient = TcpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
                .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(3000, TimeUnit.MILLISECONDS)));
 
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)));
    }
 
    @Bean
    public WebClient webClient() {
        return webClientFactory().build();
    }
 
}
 
@Slf4j
@RestController
@SpringBootApplication
@RequiredArgsConstructor
public class ExamApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ExamApplication.class, args);
    }
 
    private final WebClient client;
 
    @GetMapping("/")
    public Mono<String> hello(){
        log.info("exam server accept request");
        return client.get("http://localhost:8080/")
                .retrieveBodyToMono(String.class);
}
 
 
 
cs

 

[BServer:8080]

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Configuration
public class WebClientConfig {
 
    @Bean
    public WebClient.Builder webClientFactory(){
        TcpClient tcpClient = TcpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2000)
                .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(5000, TimeUnit.MILLISECONDS)));
 
        return WebClient.builder()
                .clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient)));
    }
 
    @Bean
    public WebClient webClient() {
        return webClientFactory().build();
    }
 
}
 
@Slf4j
@RestController
@SpringBootApplication
@RequiredArgsConstructor
public class ClientApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ClientApplication.class, args);
    }
 
    private final WebServiceClient client;
 
    @GetMapping("/")
    public Mono<String> hello(){
        log.info("client server accept request");
        return client.get("http://localhost:7070/")
                .retrieveBodyToMono(String.class);
    }
 
}
 
 
cs

 

[CServer:7070]

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@SpringBootApplication
public class ServerApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(ServerApplication.class, args);
    }
 
    @GetMapping("/")
    public String hello() throws InterruptedException {
        Thread.sleep(10000);
        return "hello";
    }
 
}
cs

 

이제 "http://localhost:9090/" 으로 요청을 보내보자!

 

 

보면 AServer(exam)은 ReadTimeOut Exception이 발생하였고, subscription was cancelled라는 메시지를 남기고 있다. 혹시나 이러한 문제가 발생한 사람이 있다면.. 적절히 서버들의 WebClient 옵션을 조정해줄 필요가 있거나 혹은 비정상적으로 응답이 느린 구간이 있을 수 있다. 하지만 때때로 클라이언트 애플리케이션에서 커낵션 풀을 사용하지 않아 과도하게 로컬 포트를 많이 개방한다거나 등의 문제로 cancelled되는 경우가 있으니, 여러가지 상황을 고려해보아야 한다.

 

마지막으로 ConnectionTimeOut, ReadTimeOut, SocketTimeOut의 차이점을 모른다면 아래 링크를 확인하자.

 

2019/02/12 - [프로그래밍언어/Java&Servlet] - Java - ConnectionTimeout,ReadTimeout,SocketTimeout 차이점?

 

Java - ConnectionTimeout,ReadTimeout,SocketTimeout 차이점?

Java - ConnectionTimeout,ReadTimeout,SocketTimeout 차이점? 사실 지금까지 웹개발을 해오면서 ConnectionTimeout,ReadTimeout,SocketTimeout에 대해 대략적으로만 알고있었지 사실 정확히 설명해봐라 혹은 차이..

coding-start.tistory.com

 

posted by 여성게
:
Web/Spring 2020. 2. 3. 21:54

 

기본적으로 MongoDB는 ObjectId라는 유니크한 primary id를 갖는다. 하지만 @Id 어노테이션을 특정 Class로 매핑시키기 위한 방법은 없을까? 예를 들어 아래와 같은 상황이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Document
@Data
@AllArgsConstructor
@NoArgsConstructor
public class DocumentData {
 
    @Id
    private CustomId id;
    private String value;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class CustomId implements Serializable {
        private String idPrefix;
        private String idDelemeter;
    }
}
cs

 

DocumentData라는 Collection이 있고, 해당 Collection에는 ObjectId 타입이 아닌 CustomId 오브젝트 타입의 Id를 넣고 싶은 것이고, 실제로 Id는 내부적으로 String 타입이며 idPrefix + idDelemeter 라는 스트링으로 매핑하고 싶다. 즉, CustomId("prefix", "!"); 로 생성된 오브젝트가 있고 이것이 실제로 Mongodb에 들어가면 _id : "prefix!" 인 형태로 저장이 하고 싶은 것이다. 이럴때는 어떻게 해야할까?

 

1
2
3
4
5
6
7
8
9
10
11
    @Bean
    public MongoCustomConversions customConversions() {
        return new MongoCustomConversions(Collections.singletonList(new CustomIdConverter()));
    }
 
    public static class CustomIdConverter implements Converter<DocumentData.CustomId, String> {
        @Override
        public String convert(DocumentData.CustomId source) {
            return source.getIdPrefix() + source.getIdDelemeter();
        }
    }
cs

 

위와 같이 MongoCustomConversions를 Bean으로 등록하면 특정 오브젝트 타입이 @Id로 Mapping 되어 있을 때, 구현한 Converter 내용에 따라 적절히 _id 값이 들어가게 된다. 위의 구현은 CustomId 오브젝트 타입이 @Id로 매핑되어 있을 때, idPrefix와 IdDelemeter를 조합하여 String Type의 _id 값을 만들어 낸다. 그리고 기존의 ObjectId로 매핑한 @Id도 그대로 사용가능하다.

 

1
2
3
4
5
6
7
8
public interface MongoRepositoryImpl extends ReactiveMongoRepository<DocumentData, DocumentData.CustomId> {}
 
@PostMapping("/mongo")
public Mono<DocumentData> save(){
    DocumentData data = new DocumentData(DocumentData.CustomId.of("prefix","!"), "value");
    mongoRepository.save(data);
    return mongoRepository.save(data);
}
cs

 

실제로 간단히 API를 만들어 요청을 보내보면 아래와 같이 데이터가 삽입되어 있다.

 

1
2
3
_id : "prefix!"
value : "value"
_class : "com.webflux.mongoexam.DocumentData"
cs

 

실제 필자도 내부적으로 자체적인 복잡한 ID 체계를 가져가기 위해 MongoCustomConversions를 빈으로 등록하여 사용하고 있다.

 

추가적으로 하나더 이슈가 있다. 만약에 특정 커스텀 클래스를 @Id class로 사용하려면 해당 클래스가 반드시 Serializable을 구현하는 클래스이어야한다! 이 이슈를 발견하게 된 것은 ReactiveMongoRepository의 findById를 사용할때 였다. 아이디클래스가 Serializable을 구현하지 않으면 예외를 내뱉는다.

 

 

{
    logEvent: "java.lang.ClassCastException",
    errorMsg: "class xxx cannot be cast to class java.io.Serializable (xxx is in unnamed module of loader org.springframework.boot.devtools.restart.classloader.RestartClassLoader @2c4eab42; java.io.Serializable is in module java.base of loader 'bootstrap')"
}

 

posted by 여성게
:
Web/Spring 2019. 10. 23. 21:33

응답으로 나가는 Object에 getter가 존재하지 않아서 Response Content-type을 추론하지 못했다. 그래서 필터 단에서 임의로 DefaultMediaType으로 */*? * 로 넣어버리지만 사실 Content-Type에 wildCard는 들어가지 못한다. 즉, 예외발생!

posted by 여성게
:
Web/Spring 2019. 10. 18. 00:33

 

오늘 다루어볼 포스팅은 ResponseBodyAdvice를 이용하여 컨트롤러 응답값을 가공해보는 예제이다. 사실 HandlerInterceptor의 postHandler 같은 곳에서 응답값을 가공할 수 있을 듯하지만, 사실 인터셉터 단에서 응답 가공은 불가능하다. 하지만 우리는 적절한 값으로 응답을 가공하고 싶을 때가 있는데, 그럴때 사용하는 것이 ResponseBodyAdvice이다.

 

예제 상황은 다음과 같다.

 

"Controller에서 응답값을 Enum class로 둔다. 하지만 실제 클라이언트에는 특정 응답용 객체로 컨버팅 후 내려준다."

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@RestControllerAdvice
public class EnumResponseCtrlAdvice implements ResponseBodyAdvice<Object> {
 
    @Override
    public boolean supports(MethodParameter returnType, Class<extends HttpMessageConverter<?>> converterType) {
        return true;
    }
 
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        return body instanceof EnumController.ResponseEnum ? new Response((EnumController.ResponseEnum) body) : body;
    }
 
    @Data
    @AllArgsConstructor
    class Response{
        EnumController.ResponseEnum status;
    }
}
 
@RestController
@RequestMapping
public class EnumController {
 
    @GetMapping
    public ResponseEnum enumResponse(){
        return ResponseEnum.SUCCESS;
    }
 
    @Getter
    enum ResponseEnum{
        SUCCESS("success"),FAIL("fail");
 
        String value;
        ResponseEnum(String value){this.value=value;}
 
    }
 
}
 
=>최종응답
{
"learnableStatus""LEARNABLE"
}
cs

 

어렵지 않다. 응답값은 분명 Enum class 인데, 클라이언트에 반환된 응답값은 ResponseEnum 타입의 오브젝트로 리턴되었다. 공통적인 응답 가공이 필요할때 사용하면 좋을 듯하다. 

posted by 여성게
:
Web/Spring 2019. 10. 12. 12:49

 

이번에 다루어볼 포스팅은 커스텀 어노테이션과 인터셉터를 이용하여 특정 컨트롤러의 매핑 메서드에 전처리(인증 등)를 하는 예제이다. 즉, 커스텀하게 어노테이션을 정의하고 컨트롤러 혹은 컨트롤러 메서드에 어노테이션을 붙여(마치 @RequestMapping과 같은) 해당 컨트롤러 진입전에 전처리(인증)을 하는 예제이다. 물론, Allow Ip 같은 것은 필터에서 처리하는 것이 더 맞을 수 있지만, 어떠한 API는 인증이 필요하고 어떠한 것은 필요없고 등의 인증,인가 처리는 인터셉터가 더 적당한 것 같다. 이번 포스팅을 이해하기 위해서는 Spring MVC의 구동 방식을 개념정도는 알고 하는 것이 좋을 듯하다. 아래 포스팅을 확인하자.

 

 

spring의 다양한 intercept 방법 (Servlet Filter, HandlerInterceptor, PreHandle, AOP)

1. 다양한 intercept 방법들과 주 사용처 Servlet Filter : 인코딩, 인증, 압축, 변환 등 HandlerInterceptor : 세션, 쿠키, 검증 등 AOP : 비즈니스단 로깅, 트랜잭션, 에러처리 등 2. Servlet Filter 와 Handler..

sjh836.tistory.com

 

개발 방향은 컨트롤러에 인증을 뜻하는 어노테이션을 커스텀하게 붙이고, 인터셉터에서 어노테이션의 유무를 판단하여 인증을 시도한다. 특별히 어려운 코드는 없어 간략한 설명만 하고 넘어가겠다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
public enum Auth {
 
    NONE,AUTH
 
}
 
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsAuth {
 
    Auth isAuth() default Auth.NONE;
 
}
 
@Slf4j
@Component
public class CommonInterceptor implements HandlerInterceptor {
 
    private Map<String,User> userMap = new HashMap<>();
 
    @PostConstruct
    public void setup() {
        User user = new User("","yeoseong_gae",28);
        userMap.put("yeoseong-gae",user);
    }
 
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        log.info("interceptor");
        String userId = request.getParameter("userId");
 
        IsAuth annotation = getAnnotation((HandlerMethod)handler, IsAuth.class);
 
        Auth auth = null;
 
        if(!ObjectUtils.isEmpty(annotation)){
            auth = annotation.isAuth();
            //NONE이면 PASS
            if(auth == Auth.AUTH){
                if(ObjectUtils.isEmpty(userMap.get(userId))){
                    log.info("auth fail");
                    throw new AuthenticationException("유효한 사용자가 아닙니다.");
                }
            }
        }
        return true;
    }
 
    private <extends Annotation> A getAnnotation(HandlerMethod handlerMethod, Class<A> annotationType) {
        return Optional.ofNullable(handlerMethod.getMethodAnnotation(annotationType))
                .orElse(handlerMethod.getBeanType().getAnnotation(annotationType));
    }
 
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public class User{
        private String id;
        private String name;
        private int age;
    }
}
 
@Slf4j
@RestControllerAdvice
public class CtrlAdvice {
 
    @ExceptionHandler(value = {Exception.class})
    protected ResponseEntity<String> example(Exception exception,
                                             Object body,
                                             WebRequest request) throws JsonProcessingException {
        log.debug("RestCtrlAdvice");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("message:"+exception.getMessage());
    }
 
}
 
@RestController
public class AuthController {
 
    @IsAuth(isAuth = Auth.AUTH)
    @GetMapping("/auth")
    public boolean acceptUser(@RequestParam String userId){
        return true;
    }
 
    @IsAuth
    @GetMapping("/nonauth")
    public boolean acceptAll(@RequestParam String userId){
        return true;
    }
}
cs

 

마지막으로 인터셉터를 등록하기 위한 Config 클래스이다.

 

1
2
3
4
5
6
7
8
9
10
11
@RequiredArgsConstructor
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
 
    private final CommonInterceptor commonInterceptor;
 
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(commonInterceptor);
    }
}
cs

 

커스텀한 어노테이션을 정의하고, 컨트롤러 매핑 메서드중 인증을 하고 싶은 곳에 어노테이션을 등록해준다. 그리고 인터셉터에서 현재 찾은 핸들러의 핸들러메서드에 우리가 등록한 커스텀한 어노테이션이 있는지 혹은 해당 핸들러메서드의 클래스에 붙어있는지 등을 리플렉션을 이용하여 확인한다. 만약 있다면 유효한 사용자인지 판단한다. 참고로 인터셉터에서는 디스패처 서블릿 더 뒷단에서 구동하기 때문에 현재 매핑된 핸들러 메서드의 접근이 가능하다.

 

마지막으로 인증이 실패한다면 예외를 발생시키고, 컨트롤러 어드바이스를 이용하여 예외를 처리한다. 참고로 인터셉터는 앞에서 말한 것과 같이 디스패처 서블릿 뒷단에서 구동되기때문에 ControllerAdvice 사용이 가능하다.

 

현재 다루고 있는 예제는 하나의 예시일 뿐이다. 사실 스프링 시큐리티를 사용한다면 굳이 필요없는 예제일수 있다. 하지만 인증뿐아니라 여러가지로 사용될수 있기에 응용하여 사용하면 될듯하다. 해당 코드는 절대 실무에선 사용될 수없는 로직을 갖고 있다.(예제를 위해 간단히 작성한 코드이기에) 컨셉을 갖고 더 완벽한 코드로 완성해야 한다. 만약 해당 코드가 필요하다면 밑의 깃헙에 소스를 올려놓았다.

 

 

 

yoonyeoseong/useAnnotationAndInterceptorAuth

Contribute to yoonyeoseong/useAnnotationAndInterceptorAuth development by creating an account on GitHub.

github.com

 

posted by 여성게
:
Web/Spring 2019. 10. 10. 00:22

 

오늘 포스팅할 내용은 간단하게 Springboot Test이다. 아주 자세하게 다루어보진 않지만, 유용하고 자주 사용하는 부분이다. 간단하게 유닛테스트하는 내용은 이전 포스팅을 참고하고, 이번 내용은 Springboot 환경에서의 테스트 내용을 다룰것이다.

 

TDD - 테스트 주도 개발(Test Driven Development)

작성하려는 코드가 있다면 항상 먼저 어떻게 그 코드를 테스트할지 고민해야한다. 코드를 작성한 후에 어떻게 테스트할지 고민하기보다 작성할 코드를 묘사하는 테스트를 설계해야 한다. 이것이 테스트 주도 개발..

coding-start.tistory.com

 

다음 코드는 Springboot에서 빈으로 등록된 객체들을 사용할수 있게하는 테스트이다. 즉, ApplicationContext를 띄우는 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
============application.properties============
test.property=test-property
==============================================
@Service
public class CommonService {
    @Autowired
    private CommonBean commonBean;
 
    public String print(){
        String result = commonBean.print();
        System.out.println(result);
        return result;
    }
}
@Component
public class CommonBean {
    @Value("${test.property}")
    private String testProperty;
    public String print(){
        System.out.println(testProperty);
        return testProperty;
    }
}
 
@RunWith(SpringRunner.class)
@SpringBootTest
public class TestPropertyTest {
    @MockBean
    private CommonBean commonBean;
    @Autowired
    private CommonService commonService;
    @Test
    public void print(){
        commonBean.print();
    }
}
 
cs

 

위 테스트는 성공적으로 통과할 것이다. 하지만 여기서 하나 문제점이 존재한다. 만약 아주 큰 애플리케이션이라 ApplicationContext 로드에 아주 긴 시간이 걸린다면 작은 테스트를 하나 수행할때마다, 긴 시간이 걸릴 것이다. 이러할때, 우리가 테스트에 필요한 빈만 띄울수 있다. 아래 코드를 확인하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
        CommonService.class,
        CommonBean.class
},initializers = ConfigFileApplicationContextInitializer.class)
public class TestPropertyTest {
 
    @Autowired
    private CommonService commonService;
 
    @Test
    public void print(){
        commonService.print();
    }
 
}
cs

 

2가지 방법이 존재한다. 첫번째 방법은 @ContextConfiguration 어노테이션을 이용하는 방법이다. 인자로 빈으로 띄울 클래스를 넘긴다. 여기서 중요한것을 관련된 클래스도 모두 넣어줘야한다는 것이다. 위를 예를 들면 CommonService 클래스를 빈으로 등록할 것인데, 해당 클래스의 내용을 보면 CommonBean을 DI받고 있으므로, CommonBean도 빈으로 띄워줘야하는 것이다. 나중에 다루어볼 것이지만 위와같이 빈으로 띄울 클래스를 명시적으로 등록해도되지만, 굳이 테스트에 사용하지 않으면 Mock 빈으로 등록해주어도 된다. 두번째 인자는 initializers이다. 만약 이 인자가 없다면, CommonBean에서 @Value로 DI 받고있는 프로퍼티값을 주입받지 못한다. 즉, application.properties의 내용을 읽지 못하는 것이다. 내부적으로 @Value를 받기위한 빈이 뜨지 못해서 그런 것 같다. 이와 같은 문제를 해결하기 위해서 initializers = ConfigFileApplicationContextInitializer.class를 등록해주므로써 프로퍼티값을 읽어 올 수 있게 하였다. 

 

두번째 방법은 아래와 같다. @SpringBootTest를 이용하여 더 간단한 설정으로 SpringBoot Test를 위한 설정을 할 수 있게된다. 또한 테스트에만 사용할 프로퍼티를 정의할 수도 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
@RunWith(SpringRunner.class)
@SpringBootTest(classes = {CommonBean.class,CommonService.class}
,properties = {"test.property=dev-property"})
public class TestPropertyTest {
 
    @Autowired
    private CommonService commonService;
 
    @Test
    public void print(){
        commonService.print();
    }
 
}
cs

 

두번째는 Mock빈을 이용하는 방법이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RunWith(SpringRunner.class)
@ContextConfiguration(classes = {
        CommonService.class
},initializers = ConfigFileApplicationContextInitializer.class)
public class TestPropertyTest {
 
    @MockBean
    private CommonBean commonBean;
    @Autowired
    private CommonService commonService;
 
    @Test
    public void mockBeanTest(){
        given(commonBean.print()).willReturn("Test Result");
        commonService.print();
    }
 
}
cs

 

CommonService의 비즈니스 로직을 테스트하고 싶은데, 굳이 CommonBean은 띄울 필요가 없을때, 위와 같이 MockBean을 등록해서 임의로 Mock 빈이 리턴할 값을 명시할 수 있다. 위 소스는 CommonService만 테스트를 위한 빈으로 띄우는데, 내부적으로 관련있는 CommonBean은 띄우지 않고 Mock 빈으로 띄운다. 그리고 CommonBean이 리턴하는 값을 임의로 "Test Result"라는 값으로 지정해주었다. 이렇게 테스트할 빈만 집중하여 테스트가 가능해지며 사이드 코드의 양을 줄일 수 있게 된다.

 

다음은 간단하게 Controller를 테스트할 수 있는 코드이다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RunWith(SpringRunner.class)
@WebMvcTest(controllers = CommonTestController.class)
@ContextConfiguration(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class}
        ,initializers = ConfigFileApplicationContextInitializer.class)
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

굳이 스프링을 실행시키지 않고도 컨트롤러를 테스트할 수 있다. @WebMvcTest 어노테이션을 이용하여 스프링 컨트롤러 테스트가 가능한 설정을 띄워준다. 즉, MVC관련 빈만 띄워 더욱 가볍게 스프링 컨트롤러 테스트가 가능하다. 위와 같은 어노테이션도 가능하며 아래와 같은 어노테이션 설정도 사용가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class
})
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

@AutoConfigureMockMvc 어노테이션을 이용하여 MVC 테스트를 위한 설정을 AutoConfiguration하게 할수 있다. 물론, MVC테스트를 위하여 내부적으로 내/외부 연동코드가 있다면 내/외부연동을 하지 않기 위해 Mock 빈등을 이용하여 테스트 가능하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class
})
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private CommonBean commonBean;
    @MockBean
    private CommonService commonService;
 
    @Test
    public void resultTest() throws Exception {
        given(commonBean.print()).willReturn("test");
        given(commonService.print()).willReturn("test");
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

마지막으로 테스트만을 위한 application.properties를 사용는 방법이다. 우선, application-test.properties를 classpath 밑의 resources 폴더 안에 넣어준다.(기존 application.properties 경로와 동일) 그리고 아래와 같이 @ActiveProfile 어노테이션을 이용하여 프로파일을 이용하여 읽어올 프로퍼티파일을 명시한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
@SpringBootTest(classes = {
        CommonTestController.class,
        CommonBean.class,
        CommonService.class
})
@ActiveProfiles("test")
public class TestControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @Test
    public void resultTest() throws Exception {
        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/test/common")).andReturn();
        System.out.println(result.getResponse().getContentAsString());
    }
 
}
cs

 

위 코드는 기존 application.properties가 아닌, application-test.properties 파일을 읽어 컨텍스트를 구성할 것이다.

 

아주 간단하게만 springboot test를 다루어봤는데, 추후 포스팅에서 지금까지 다룬 내용과 더 자세한 테스팅관련 기술을 다루어볼 것이다.

posted by 여성게
:
Web/Spring 2019. 9. 11. 23:54

 

이번에 다루어 볼 포스팅은 Spring Cache이다. 스프링 3.1부터 빈의 메서드에 캐시 서비스를 적용할 수 있는 기능을 제공한다. 캐시 서비스는 트랜잭션(@Transaction)과 마찬가지로 AOP를 이용해 메서드 실행 과정에 우리가 모르도록 투명하게 적용된다. 또한 스프링은 장점으로 캐시 서비스 구현 기술에 종속되지 않도록 추상화된 서비스를 제공해주므로 환경이 바뀌거나 구현 기술이 바뀌어도 우리가 적용한 코드의 변경 없이 구현 기술을 바꿔줄 수 있다.

 

그렇다면 여기서 짚고 넘어갈 것이, "애플리케이션 빈의 메서드에 캐시를 적용하는 이유 혹은 목적"은 무엇일까?

 

캐시는 기본적으로 성능의 향상을 위해 사용한다. 여기서 성능 향상은 애플리케이션이 갑자기 막 빨라지고 하는 성능 향상이 아니다. 어떤 요청을 처리하는데 연산이 아주 복잡하거나 데이터베이스와 같은 백엔드 시스템에 많은 부하를 주거나, 외부 API 호출처럼 시간이 오래 걸린다면 캐시의 사용을 고려해볼 만하다. 하지만 결코 앞과 같은 상황에 직면했다고 캐시 기능을 사용하면 안된다.

 

캐시(Cache)는 임시 저장소라는 뜻이다. 복잡한 계산이나 데이터베이스 작업, 외부 API 요청의 처리 결과 등을 임시 저장소인 캐시에 저장해뒀다가 동일한 요청이 들어오면 복잡한 작업을 수행해서 결과를 만드는 대신 이미 캐시에 보관된 결과를 바로 돌려주는 방식이다. 만약 이런 상황이 있다고 생각하자.

 

사용자가 많은 웹사이트 서비스가 있다. 메인 페이지에 접속하면 항상 공지사항이 출력돼야 한다. 공지사항은 어떠한 추가적인 내용이 추가되거나 혹은 내용이 수정되기 전까지는 모든 사용자에게 동일한 내용을 보여준다. 그렇다면 사용자가 만명이 있고 사용자가 들어올때 마다 공지사항을 데이터베이스에 접근하여 보여준다면 총 만번의 데이터베이스 액세스가 이루어질 것이다. 사실 데이터베이스에 자주 접근하는 연산은 비용이 비싸다. 그만큼 데이터베이스에 부하가 간다는 뜻이다. 만약 이럴때 캐시를 적용한다면 첫 사용자가 접근할때만 데이터베이스에서 공지사항 내용을 가져오고 나머지 9999명은 캐시에 저장된 내용을 그대로 돌려준다면 만명의 사용자가 한번의 데이터베이스 액세스로 공지사항을 볼 수 있게 되는 것이다.

 

이렇게 캐시는 여러가지 장점을 가졌지만, 그렇다면 모든 상황에서 캐시를 사용하면 안된다. 

 

"값비싼 비용이 들어가는 요청, 데이터베이스 접근, 외부 요청 등에 캐시를 사용하지만 여기서 전제가 있다. 캐시는 반복적으로 동일한 결과를 주는 작업에만 이용해야 한다. 매번 다른 결과를 돌려줘야 하는 작업에는 캐시를 적용해봐야 오히려 성능이 떨어진다."

 

그렇다면 캐시를 사용하면 아주 주의깊게 체크해야 할 것이 있다.  바로, 캐시에 저장해둔 컨텐츠의 내용이 바뀌는 상황 혹은 시점을 잘 파악하는 것이다. 공지사항이 추가되거나 수정되었다면 캐시는 아직까지 이전 내용의 공지사항을 가지고 있을 것이므로, 이러한 상황에서는 캐시에 저장된 내용을 삭제하고 첫 사용자가 접근하여 캐시의 내용을 최신 내용으로 업데이트 해야한다.

 

이제 직접 코드를 보며 스프링에서 사용할 수 있는 캐시 서비스를 알아보자. 우선 캐시 기능을 사용하기 위해서는 @EnableCaching 애너테이션을 달아주어야 한다.

 

애너테이션을 이용한 캐시 기능 사용(@Cacheable)

스프링의 캐시 서비스 추상화는 AOP를 이용한다. 캐시 기능을 담은 어드바이스는 스프링이 제공한다. 이를 적용할 대상 빈과 메서드를 선정하고 속성을 부여하는 작업은 <aop:config>,<aop:advisor> 같은 기본 AOP 설정 방법을 이용할 수 있다. 하지만 이보다는 @Transaction 애노테이션을 이용하는 것처럼 캐시 서비스도 애너테이션을 이용할 수 있다.

 

캐시 서비스는 보통 메서드 단위로 지정한다. 클래스나 인터페이스 레벨에 캐시를 지정할 수도 있지만 캐시의 특성상 여러 메서드에 일괄적인 설정을 하는 경우는 드물다. 캐시에 저장할 내용과 캐시 설정 정보로 메서드의 리턴 값과 메서드 파라미터를 사용하기에 메서드 레벨로 적용하는 것이 수월하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Product {
 
    private int productId;
    private ProductType type;
    private String productName;
    private boolean isBest;
 
}
 
@Slf4j
@Service
public class ProductService {
 
    @Cacheable("product")
    public List<Product> bestProductsByType(ProductType productType){
 
        log.info("ProductService.bestProductsByType");
 
        List<Product> bestProducts = new ArrayList<>();
        if(productType.equals(ProductType.TOP)){
            bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        }else if(productType.equals(ProductType.PANTS)){
            bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        }else{
            bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        }
 
        return bestProducts;
    }
 
    @Cacheable(value = "product", key = "#productType")
    public List<Product> bestProduct(User user, LocalDateTime time, ProductType productType){
 
        log.info("ProductService.bestProduct");
 
        List<Product> bestProducts = new ArrayList<>();
        if(productType.equals(ProductType.TOP)){
            bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        }else if(productType.equals(ProductType.PANTS)){
            bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        }else{
            bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        }
 
        return bestProducts;
    }
 
    @Cacheable(value = "product", condition = "#user.firstName == 'sora'")
    public List<Product> bestProductForGoldUser(User user){
 
        log.info("ProductService.bestProductForGoldUser");
 
        List<Product> bestProducts = new ArrayList<>();
        bestProducts.add(new Product(1,ProductType.OUTER,"CHANNEL OUTER",true));
        return bestProducts;
    }
 
    @Cacheable("product")
    public List<Product> allBestProduct(){
 
        log.info("ProductService.allBestProduct");
 
        List<Product> bestProducts = new ArrayList<>();
        bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        return bestProducts;
    }
 
}
cs

 

위 코드는 @Cacheable 애너테이션을 이용하여 캐시 기능을 사용하는 예제 코드이다. 우선 첫번째로 ProductService 클래스의 첫번째 메서드를 살펴보자. 해당 메서드가 처음 호출되는 순간에 캐시에 "product"라는 이름의 공간이 생긴다. 그리고 파리미터로 productType(ProductType.TOP이라는 enum이 들어왔다 생각)이 들어오는데, 해당 값을 이용하여 키를 만든다. 이 말은 "product"라는 캐시 공간에 특정 키값으로 데이터를 캐시한다는 뜻이다. 그리고 첫번째 호출 이후에 동일한 메서드에 동일한 파라미터로 요청이 들어오면 "product"라는 캐시 공간에 ProductType.TOP을 이용한 키값을 가진 데이터가 있는 지 확인하고 만약 데이터가 존재하면 해당 메서드를 호출하지 않고 캐시에 있는 데이터를 돌려준다. 만약에 해당 키값으로 데이터가 없다면 메서드 로직을 수행한 후에 반환 값을 캐시에 적재한다.

 

그렇다면 두번째 메서드처럼 파라미터가 여러개인 메서드라면 어떻게 할까? 답은 바로 코드에 있다. 바로 key라는 설정 값에 SpEL식을 이용해 키값으로 사용할 파라미터 값을 명시할 수 있다. 그렇다면 이제 이 메서드는 명시한 파라미터로만 키값을 생성하게 된다. 만약 파라미터가 특정 객체로 들어온다면 key = "Product.productType" 과 같이 설정할 수 있다. 만약 키값으로 사용할 어떠한 설정 내용도 명시하지 않는 다면 어떻게 처리할까? 바로 여러개의 파라미터의 hashCode()를 조합하여 키값을 생성한다. 그렇지만 해시 코드 값의 조합이 키로서 의미가 있다면 문제가 없지만 대부분은 그렇지 않은 경우가 많기에 key 설정값을 이용하여 키로 사용할 파라미터를 명시해주는 것이 좋다.

 

세번째는 특정 조건에서만 캐싱을 하고 나머지 상황에서는 캐싱을 하고 싶지 않은 경우가 있다. 이럴 경우에는 세번째 메서드처럼 condition이라는 설정 값을 이용한다. 이 설정은 파라미터가 특정 값으로 들어올때만 캐시하고 싶을 때, 사용가능하다. 예제는 위와 같이 사용하면 된다.

 

마지막으로 메서드에 매개변수가 없다면 어떻게 될까? 이럴때는 디폴트 키 값으로 세팅이 되기 때문에 메서드가 처음 호출되면 무조건 데이터가 캐싱이 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {
 
    @Autowired
    ProductService service;
    List<Product> bestProducts;
    User basicUser;
    User goldUser;
 
    @Before
    public void init(){
        bestProducts = new ArrayList<>();
        basicUser = new User(1,"yeoseong","yoon",28, Level.BASIC);
        goldUser = new User(1,"sora","hwang",30, Level.GOLD);
    }
 
    @Test
    public void noArgCacheTest(){
        bestProducts.addAll(service.allBestProduct());
 
        MatcherAssert.assertThat(bestProducts.size(), CoreMatchers.equalTo(3));
 
        service.allBestProduct();
    }
 
    @Test
    public void oneArgCacheTest(){
 
        bestProducts.addAll(service.bestProductsByType(ProductType.TOP));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI SHIRTS"));
 
        service.bestProductsByType(ProductType.TOP);
 
        clearList(bestProducts);
 
        bestProducts.addAll(service.bestProductsByType(ProductType.PANTS));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI PANTS"));
 
        service.bestProductsByType(ProductType.PANTS);
 
        clearList(bestProducts);
 
        bestProducts.addAll(service.bestProductsByType(ProductType.OUTER));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI OUTER"));
 
        service.bestProductsByType(ProductType.OUTER);
 
    }
 
    @Test
    public void manyArgsCacheTest(){
        bestProducts.addAll(service.bestProduct(basicUser, LocalDateTime.now(),ProductType.TOP));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI SHIRTS"));
 
        service.bestProduct(goldUser, LocalDateTime.now(),ProductType.TOP);
    }
 
    @Test
    public void conditionCacheTest(){
        bestProducts.addAll(service.bestProductForGoldUser(basicUser));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("CHANNEL OUTER"));
 
        service.bestProductForGoldUser(goldUser);
        service.bestProductForGoldUser(goldUser);
    }
 
    private void clearList(List<Product> products){
        products.clear();
    }
 
}
cs

 

위의 테스트 코드를 실행시키면 캐시가 적용된 이후에는 더 이상 ProductService 메서드의 로그가 출력되지 않을 것이다. 그 말은 캐시가 적용되어있다면 메서드 자체를 호출하지 않는 것을 알 수 있다.

 

캐시 데이터 삭제(@CacheEvict)

캐시는 적절한 시점에 제거돼야 한다. 캐시는 메서드를 실행했을 때와 동일한 겨로가가 보장되는 동안에만 사용돼야 하고 메서드 실행 결과가 캐시 값과 달리지는 순간 제거돼야 한다. 캐시를 적절히 제거해주지 않으면 사용자에게 잘못된 결과를 리턴하게 된다.

 

캐시를 제거하는 방법은 두 가지가 있다. 하나는 일정한 주기로 캐시를 제거하는 것과 하나는 캐시에 저장한 값이 변경되는 상황이 생겼을 때 캐시를 삭제하는 것이다.

 

캐시의 제거에도 AOP를 이용한다. 간단하게 메서드에 @CacheEvict 애너테이션을 붙여주면 된다. 캐시 삭제도 캐시 적재와 동일하게 키값을 기준해 적용한다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @CacheEvict("product")
    public void clearProductCache(){
        log.info("ProductService.clearProductCache");
    }
 
    @CacheEvict(value = "product", key = "#productType")
    public void clearProductCache(ProductType productType){
        log.info("ProductService.clearProductCache");
    }
 
    @CacheEvict(value = "product", condition = "#user.firstName == 'sora'")
    public void clearProductCache(User user){
        log.info("ProductService.clearProductCache");
    }
    //product의 모든 키값에 해당하는 캐시 데이터 삭제
    @CacheEvict(value = "product", allEntries = true)
    public void clearProductCacheAll(){
        
    }
cs

 

위의 코드를 이용하여 테스트 코드를 만들어 로그가 찍히는 횟수를 확인하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {
 
    @Autowired
    ProductService service;
    List<Product> bestProducts;
    User basicUser;
    User goldUser;
 
    @Before
    public void init(){
        bestProducts = new ArrayList<>();
        basicUser = new User(1,"yeoseong","yoon",28, Level.BASIC);
        goldUser = new User(1,"sora","hwang",30, Level.GOLD);
    }
 
    @Test
    public void clearCacheTest(){
        service.allBestProduct();
        service.clearProductCache();
        service.allBestProduct();
        System.out.println("===================================");
        service.bestProductsByType(ProductType.TOP);
        service.bestProductsByType(ProductType.PANTS);
        service.clearProductCache(ProductType.TOP);
        service.bestProductsByType(ProductType.TOP);
        service.bestProductsByType(ProductType.PANTS);
        System.out.println("===================================");
        service.bestProductsByType(ProductType.TOP);
        service.clearProductCache();
        service.bestProductsByType(ProductType.TOP);
        System.out.println("===================================");
        service.bestProductForGoldUser(goldUser);
        service.clearProductCache(basicUser);
        service.bestProductForGoldUser(goldUser);
        service.clearProductCache(goldUser);
        service.bestProductForGoldUser(goldUser);
    }
 
}
cs

 

이외에 @CachePut이라는 애너테이션이 존재한다. 이 애너테이션은 캐시에 값을 저장하는 용도로 사용한다. @Cacheable과 비슷하게 메서드 실행 결과를 캐시에 저장하지만 @CachePut은 캐시 데이터를 사용하는 것이 아니고 해당 애너테이션이 붙은 메서드를 호출 할때마다 결과 데이터를 캐시에 적재한다. 보통 한 번에 캐시에 많은 정보를 저장해두는 작업이나, 다른 사용자가 참고할 정보를 생성하는 용도로만 사용되는 메서드에 이용할 수 있다.

 

Cache Manager

스프링의 캐시 서비스는 AOP를 이용해 애플리케이션 코드를 수정하지 않고도 캐시 부가기능을 메서드에 적용할 수 있게 해준다. 동시에 캐시 기술의 종류와 상관없이 후상화된 스프링 캐시 API를 이용할 수 있게 해주는 서비스 추상화를 제공한다. 캐시 기능을 적용하는 AOP 어드바이스는 스프링이 제공해주는 것을 애너테이션을 통해 적용하면 되므로, 우리가 신경 쓸 부분은 적용할 캐시 기술을 선정하고 캐시 관련 설정을 넣어주는 것이다. 

 

캐시 추상화에서는 적용할 캐시 기술을 지원하는 캐시 매너저를 빈으로 등록해줘야 한다. 여기서는 자세한 설정법은 다루지 않고 캐시 매니저에는 무엇이 있는 지만 다루어 볼 것이다.

 

캐시 매니저 설명
ConcurrentMapCacheManager ConcurrentMapCache 클래스를 캐시로 사용하는 캐시 매니저다. ConcurrentHashMap을 이용해 캐시 기능을 구현한 간단한 캐시다. 캐시 정보를 Map 타입으로 메모리에 저장해두기 때문에 빠르고 별다른 설정이 필요없다는 장점이 있지만, 실제 서비스에서 사용하기에는 기능이 빈약하다. 캐시별 용량 제한이나 다양한 저장 방식 지원, 다중 서버 분산과 같이 고급 캐시 프레임워크가 제공하는 기능을 지원하지 않는다. 따라서 저장될 캐시 양이 많지 않고 간단한 기능에 적용할때 혹은 테스트 용도로만 사용해야 한다.
SimpleCacheManager 기본적으로 제공하는 캐시가 없다. 따라서 프로퍼티를 이용해서 사용할 캐시를 직접 등록해줘야 한다. 스프링 Cache 인터페이스를 구현해서 캐시 클래스를 직접 만드는 경우 테스트에서 사용하기 적당하다.
EhCacheCacheManager 자바에서 가장 인기있는 캐시 프레임워크의 하나인 EhCache를 지원하는 캐시 매니저다. 본격적으로 캐시 기능을 적용하려면 사용을 고려할만하다.
CompositeCacheManager 하나 이상의 캐시 매니저를 사용하도록 지원해주는 혼합 캐시 매니저다. 여러 개의 캐시 기술이나 캐시 서비스를 동시에 사용해야 할 때 이용할 수 있다. CompositeCacheManager의 cacheManagers 프로퍼티에 적용할 캐시 매니저 빈을 모두 등록해주면 된다.
NoOpCacheManager 아무런 기능을 갖지 않은 캐시 매니저다. 보통 캐시가 지원되지 않는 환경에서 동작할 때 기존 캐시 관련 설정을 제거하지 않아도 에러가 나지 않게 해주는 기능이다.

 

여기까지 스프링 캐시 추상화에 대해 다루어보았다. 아마 다음 포스팅은 직접 상용에서 사용할 수 있을 만한 캐시 매니저 구현를 이용해보는 포스팅이 될 것같다.

posted by 여성게
: