원래는 오늘 매 스프린트마다 요구사항 중 하나인 스프린트마다 FE에서 BE를 관통하는 기능 중 최소 하나를 FE/BE 페어로 개발
을 만족하기 위해서 제품 검색 기능
에 대해 팀원 7명이 모두 참여하는 7명의 페어몹 프로그래밍이 있을 예정이었다. 하지만 어제 중부지방에 내린 기록적 폭우로 인해 안전이 우려되어 재택 교육 공지가 올라왔고, 온라인으로 몹 프로그래밍을 진행하는 것은 최악의 효율을 낼 거라는 팀원들의 판단 하에 몹 프로그래밍은 내일로 미루고 각자 이번 스프린트에서 하기로 한 리팩토링을 어느 정도 진행하기로 했다.
나는 도메인 위주의 리팩토링
이라는 핵심 기능도 맡았지만, 이 리팩토링은 팀원들과의 상의 하에 페어로 진행하기로 결정했기 때문에, 오늘은 맡은 부분 중 좀 더 쉽고 간단한 부분인 CORS를 허용하는 origin 제한
을 구현하기로 했다. 사실 이 부분은 리팩토링이라기 보다는 fix
에 가깝다. 기존에는 ACCESS_CONTROL_ALLOW_ORIGIN
이 와일드카드(*
)로 열려 있었다. 도메인이 정해지지 않고 public IP만 열려 있던 상황에서는 언제 바뀔지 모르기 때문에 귀찮지 않게 하려고 모든 origin에 대한 CORS를 허용하고자 했다. 하지만 SOP와 CORS의 존재 의의에 대해 생각해 본다면 이는 보안적으로 굉장히 좋지 않은 부분이다. 지난 스프린트에서 도메인 주소도 설정을 했기 때문에 이제는 더이상 와일드카드로 열어줄 필요가 없었는데, 지난 스프린트에서 미처 생각하지 못하고 넘어간 부분이었다. origin을 제한하는 것은 어렵지 않았다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private static final String CORS_ALLOWED_METHODS = "GET,POST,HEAD,PUT,PATCH,DELETE,TRACE,OPTIONS";
private static final String MAIN_SERVER_DOMAIN = "https://f12.app";
private static final String MAIN_SERVER_WWW_DOMAIN = "https://www.f12.app";
private static final String TEST_SERVER_DOMAIN = "https://test.f12.app";
private static final String FRONTEND_LOCALHOST = "http://localhost:3000";
...
@Override
public void addCorsMappings(final CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedMethods(CORS_ALLOWED_METHODS.split(","))
.allowedOrigins(MAIN_SERVER_DOMAIN, MAIN_SERVER_WWW_DOMAIN, TEST_SERVER_DOMAIN, FRONTEND_LOCALHOST)
.exposedHeaders(HttpHeaders.LOCATION);
}
...
}
기존에는 allowedOrigins
에 "*"
이 들어가 있었는데, 해당 부분을 우리 프로젝트에서 실제 사용하는 도메인 상수들 + 로컬 환경에서 테스트를 하기 위한 리액트 포트인 3000번에 대해 열어주었다. 테스트 코드 역시 조금 바뀌었는데, MockMvc
의 헤더에 Origin
값을 바꿔가면서 테스트하게 되었다.
@ParameterizedTest
@ValueSource(strings = {"https://f12.app", "https://www.f12.app", "https://test.f12.app", "http://localhost:3000"})
void 특정_Origin에_CORS가_허용되어있다(String origin) throws Exception {
mockMvc.perform(
options("/api/v1/products")
.header(HttpHeaders.ORIGIN, origin)
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
)
.andExpect(status().isOk())
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, origin))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, CORS_ALLOWED_METHODS))
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.LOCATION))
.andDo(print());
}
@Test
void CORS가_허용되지_않은_Origin에서_Preflight_요청을_보내면_허용하지_않는다() throws Exception {
mockMvc.perform(
options("/api/v1/products")
.header(HttpHeaders.ORIGIN, "http://not-allowed-origin")
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
)
.andExpect(status().isForbidden())
.andDo(print());
}
혹시 몰라서 Postman을 통한 테스트(Postman으로도 헤더에 Origin 값을 넣으면 CORS를 테스트할 수 있다.)까지 진행했고, 테스트 서버 배포 이후 QA까지 완료한 결과, 의도한 대로 CORS 설정이 된 것을 확인할 수 있었다.
Member
도메인의 필드 List<InventoryProduct> inventoryProducts
는 값이 할당되지 않더라도 null
대신 빈 리스트가 들어가는 것이 더 합리적이기 때문에 필드 단에서 빈 리스트로 초기화하도록 코드를 작성했다.
@Entity
@Table(name = "member")
@EntityListeners(AuditingEntityListener.class)
@Getter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "github_id", nullable = false)
private String gitHubId;
@Column(name = "name")
private String name;
@Column(name = "image_url", length = 65535, nullable = false)
private String imageUrl;
@Column(name = "career_level")
@Enumerated(EnumType.STRING)
private CareerLevel careerLevel;
@Column(name = "job_type")
@Enumerated(EnumType.STRING)
private JobType jobType;
@BatchSize(size = 150)
@OneToMany(mappedBy = "member")
private List<InventoryProduct> inventoryProducts = new ArrayList<>();
protected Member() {
}
@Builder
private Member(final Long id, final String gitHubId, final String name, final String imageUrl,
final CareerLevel careerLevel, final JobType jobType,
final List<InventoryProduct> inventoryProducts) {
this.id = id;
this.gitHubId = gitHubId;
this.name = name;
this.imageUrl = imageUrl;
this.careerLevel = careerLevel;
this.jobType = jobType;
this.inventoryProducts = inventoryProducts;
}
...
}
우리가 의도한 대로 동작한다면, 다음의 테스트 코드를 통과해야 했지만 테스트를 통과하지 못했다.
@Test
void Builder_테스트() {
Member member = Member.builder()
.build();
assertThat(member.getInventoryProducts()).isInstanceOf(ArrayList.class);
}
member.inventoryProducts
가 빈 ArrayList인 것을 기대했지만, 실제로는 기본값을 설정해주지 않은 것 처럼 null
(참조 타입의 초기값은 null
이다.) 값이 들어가 있었다. 이는 결국 @Builder
어노테이션을 통해 빌더를 만들어줄 때 빌더의 필드값이 우리가 원본 클래스의 필드값으로 초기화해 준 값으로 초기화되지 않았기 때문이었다. 기본값 설정을 포기할 수 없었기 때문에, @Builder.Default
어노테이션을 도입하여 이 문제를 해결하였다.
@Builder.Default
어노테이션을 통한 문제 해결 과정은 여기를 참고
오늘 하기로 했지만 밀린 몹 프로그래밍, 내일 최대한 빠른 시간 내에 완료하는 것이 목표다.