slipp 서비스를 java에서 scala로 전환하면서 경험담
TRANSCRIPT
Scala를선택하기까지의 삽질 경험담
Spring + JPA + Scala 조합을 선택한 이유
SLiPP 서비스를 Java에서 Scala로 전환하는 과정
Scala 전환을 통해 얻는 이점
Spring + JPA + Scala 조합시 고려할 사항
40대를 넘어선 2013년의 어느 날
• 10년 이상 Java만 했더니 재미없다.
• 최근에 함수형 언어가 대세라더라.
• 삶이 무료하다. 뭔가 재미있는 일 좀 있으면 좋겠다.
• 40대가 되면 생긴다는 그 병
• Scheme이라는 완전히 생소한 언어에 도전
• 내용 진짜 어려움
• 실무에 적용할 곳이 보이지 않음.
• 서서히 동기부여가 되지 않으면서 중도 포기.
하지만 함수형 언어가 무엇인지에 대한 대략적인 감은 잡음
• 스터디가 항상 그렇듯이 초반
3주차까지 좋았음.
• 4주차 이후부터 스터디원이 거의
나오지 않음.
• 스터디 중도 포기. 지금까지 여러
번의 SLiPP 스터디를 진행했지만
중도 포기한 유일한 스터디
• 나도 실무에 적용할 방법을 찾지 못함
• 함수형의 극한을 맛보는 경험을 포기하고 현실과 타협하자.
• 나란 인간은 실무에 적용하지 않으면 지속적인 동기부여를
하기 힘들다.
• 욕심을 버리고 Scala로 가자.
• 스스로 자위하면서 스터디에 합류.
• 아직도 함수형 프로그래밍에 대한
욕심을 버리지 못함.
• 책 무지 좋다. 단, Scala 문법도
익숙하지 않은 초보자가 보기 너무
어려움.
• 스터디 초반 교재로 고민하다 포기함.
• 돌고 돌아 함수형 프로그래밍에 대한
욕심을 버리고 Programming in
Scala
• 욕심을 버렸는데 재미가 없다.
Scala도 스터디 끝나면 기억 속에서
사라질 거 같다.
• SLiPP을 Java에서 Scala로 전면
개편하자.
느낀 점
• 2013년 무작정 시작했던 함수형 프로그래밍에 대한 도전이 Scala를
통해 결실을 맺다.
• 너무 많은 것을 고려한 후 실행에 옮기기 보다 무작정 실행해 보는
것도 삶에 새로운 길을 만든다.
• 돌고 돌아 함수형 프로그래밍에 대한 욕심을 버리고
Programming in Scala
• 욕심을 버렸는데 재미가 없다. Scala도 스터디 끝나면 기억
속에서 사라질 거 같다.
• SLiPP을 Java에서 Scala로 전면 개편하자.
• 돌고 돌아 함수형 프로그래밍에 대한 욕심을 버리고
Programming in Scala
• 욕심을 버렸는데 재미가 없다. Scala도 스터디 끝나면 기억
속에서 사라질 거 같다.
• SLiPP을 Java에서 Scala로 전면 개편하자.
Play가 아닌 Scala + Spring + JPA 조합을 선택한
이유
• SLiPP 서비스를 처음부터 다시 구현하는 것에 대한 부담감
• Spring과 JPA 경험을 버리는 것의 아쉬움.
• 중도 포기하지 않으려면 변화를 Scala 하나로 한정.
• Java와 Scala 코드가 동시에 컴파일과 실행이 가능하도록 환경
구축함.
• Java 소스 코드
• src/main/java, src/test/java
• Scala 소스 코드
• src/main/scala, src/test/scala
<dependency><groupId>org.scala-lang</groupId><artifactId>scala-library</artifactId><version>${scala.version}</version>
</dependency>
<plugin><groupId>net.alchim31.maven</groupId><artifactId>scala-maven-plugin</artifactId><version>3.2.1</version><configuration>
<recompileMode>incremental</recompileMode><javacArgs>
<javacArg>-Xlint:unchecked</javacArg><javacArg>-Xlint:deprecation</javacArg>
</javacArgs><source>${jdk.compiler.version}</source><target>${jdk.compiler.version}</target>
</configuration><executions>
<execution><id>scala-test-compile</id><phase>test-compile</phase><goals>
<goal>testCompile</goal></goals>
</execution><execution>
<phase>process-resources</phase><goals>
<goal>compile</goal></goals>
</execution></executions>
</plugin>
• 10년 이상 이클립스 기반으로
살아왔다.
• 이클립스에서 Scala IDE 활용해
Scala 개발 시작하는 것은 당연한
수순이었다.
• 이클립스 버리고 싶지 않았는데 몇 달
후 GG
• Java 기반의 SLiPP 코드는 Spring + JPA(Spring Data JPA) 구조
• JPA 기반 Entity를 Scala로 개발 가능한지 실험
• 다른 Entity와 가장 의존관계가 적은 기능을 Scala로 먼저 변경
• JPA Entity 실험과 리팩토링 원칙에 대한 경험을 같이 진행
package net.slipp.domain.tag;
[...]
@Entity@Table(indexes = {
@Index(name = "idx_tagged_history_tag", columnList="tag_id"),@Index(name = "idx_tagged_history_question", columnList="question_id")}
)@EntityListeners({ CreatedDateEntityListener.class })public class TaggedHistory implements HasCreatedDate {
@Id@GeneratedValue(strategy = GenerationType.AUTO)private Long historyId;
@Column(name = "tag_id", nullable = false, updatable = false)private Long tagId;
@Enumerated(EnumType.STRING)@Column(name = "tagged_type", nullable = false, updatable = false, columnDefinition =
TaggedType.COLUMN_DEFINITION)private TaggedType taggedType;
public TaggedHistory(Long tagId, Long questionId, Long userId, TaggedType taggedType) {this.tagId = tagId;this.questionId = questionId;this.userId = userId;this.taggedType = taggedType;
}
[...]}
public abstract class JdbcTemplate {public void update() throws SQLException {
[…]}
public abstract String createQuery();
public abstract void setValues(PreparedStatement pstmt) throws SQLException;}
리팩토링 전
public abstract class JdbcTemplate {public void update() throws SQLException {
[…]}
public void update(String sql) throws SQLException {[…]
}
public abstract String createQuery();
public abstract void setValues(PreparedStatement pstmt) throws SQLException;}
리팩토링 중(과도기 단계)
public abstract class JdbcTemplate {public void update(String sql) throws SQLException {
[…]}
public abstract void setValues(PreparedStatement pstmt) throws SQLException;}
리팩토링 후
Scala 적용(리팩토링) 원칙
• 기존 기능을 서비스하면서 점진적으로 리팩토링한다.
• 리팩토링 단계에 컴파일 에러가 발생하는 시간을 최소화한다.
• 리팩토링 전과 후의 코드가 공존하는 단계가 반드시 필요하다.
• 이 같은 전략은 소스 코드 리팩토링 뿐 아니라 Java => Scala 전환, DB 리팩토링 또한 같다.
• 리팩토링 전과 후의 결과를 쉽게 테스트할 수 있어야 한다.
Java => Scala 전환 과정 설계(예, TaggedHistory)• TaggedHistory.java => NTaggedHistory.scala
• Scala로 변경한 코드에서 생성된 Table Schema와 기존 자바 코드에서 생성된 Table Schema가
같은지 검증한다. Table Schema가 같은 시점을 Entity 변환 완료 시점으로 가정한다.
• Table Schema를 검증하기 위한 테스트 도구가 필요.
• TaggedHistory를 사용하는 코드를 NTaggedHistory를 사용하도록 변경한다.
• 테스트한다.
• TaggedHistory Entity를 삭제한다.
• NTaggedHistory.scala => TaggedHistory.scala로 rename
package net.slipp.ndomain.tag
[...]
@Entity(name="TaggedHistory")@Table(indexes = Array(new Index(name = "idx_tagged_history_tag", columnList="tag_id"),new Index(name = "idx_tagged_history_question", columnList="question_id")))
class NTaggedHistory(t: Long, q: Long, u: Long, tType: String) extends DomainModel with NHasCreatedDate {@Id@GeneratedValue(strategy = GenerationType.AUTO)var historyId: Long = _
@Column(name = "tag_id", nullable = false, updatable = false)val tagId = t
@Column(name = "tagged_type", nullable = false, updatable = false, columnDefinition = NTaggedType.ColumnDefinition)val taggedType = tType
def this() = this(0L, 0L, 0L, null)}
매핑 중 삽질 내용 1• Scala Annotation에서 배열을 사용하는 경우 기존 Java Annotation 배열({})을 사용할 수 없었다.
Scala Annotation 배열은 Array
package net.slipp.ndomain.tag
[...]
@Entity(name="TaggedHistory")@Table(indexes = Array(
new Index(name = "idx_tagged_history_tag", columnList="tag_id"),new Index(name = "idx_tagged_history_question", columnList="question_id")))
class NTaggedHistory(t: Long, q: Long, u: Long, tType: String) extends DomainModel with NHasCreatedDate {@Id@GeneratedValue(strategy = GenerationType.AUTO)var historyId: Long = _
@Column(name = "tag_id", nullable = false, updatable = false)val tagId = t
@Column(name = "tagged_type", nullable = false, updatable = false, columnDefinition = NTaggedType.ColumnDefinition)val taggedType = tType
def this() = this(0L, 0L, 0L, null)}
매핑 중 삽질 내용 2• 매핑 과정 중 해결하지 못한 문제는 java enum을 활용해 Mapping하는 부분이다. Scala에서도
Enumeration을 사용하는 Enum이 있지만 java의 enum과는 동작방식이 달라 그대로 사용할 수
없다.
• 1차 해결 방법은 Scala Entity에서 java enum을 사용하도록 한다. Scala 코드와 Java 코드가
섞여서 사용되는 구조가 된다.
• 2차 해결 방법은 Scala Enum 또는 Scala Case Object를 활용해 해결해야 한다. 이 경우
Entity를 사용하는 Java 코드에서 Scala 클래스에 접근하지 못하는 이슈와 Entity 내부에서
매핑을 위한 변환 작업이 필요하다.
실험을 통한 결론 및 얻게 된 경험• Scala 기반으로 JPA 기반 개발 가능.
• Scala => Java API 접근은 문제 없음. But, Java => Scala API 접근에 한계가 많음을 느낌.
• Controller => Service => Repository + Entity 순서로 리팩토링 전략 수립함.
• Controller부터 Scala로 리팩토링 시작
• 리팩토링 과정
• src/main/scala의 같은 package에 NHomeController.scala를 생성
• NHomeController로 URL 하나씩 이동하면서 관련된 method 이전함.
• 컴파일 에러가 없는 상태로 만든 후 HomeController.java에서 Controller Annotation 제거함.
• 웹 서버 시작해 기능이 정상적으로 동작하는지 테스트
• 정상 동작을 확인하면 HomeController.java를 제거함.
• NHomeController 파일을 HomeController로 rename 리팩토링 진행함.
• 모든 Controller에 대해 무한 반복
private String sendMessageToFacebook(SocialUser loginUser, String link, String receiverId, String message) {
String postId = null;try {
FacebookClient facebookClient = createFacebookClient(loginUser);int i = 0;do {
if (i > 2) {break;
}
FacebookType response = facebookClient.publish(receiverId + "/feed", FacebookType.class,Parameter.with("link", link), Parameter.with("message", message));
postId = response.getId();
i++;} while (postId == null);log.debug("connect post id : {}", postId);
} catch (Throwable e) {log.error("Facebook Connection Failed : {}", e.getMessage());
}return postId;
}
리팩토링 사례 1 – java 코드
@annotation.tailrecprivate def retry[T](n: Int)(fn: => T): T = {Try { fn } match {case Success(x) => xcase _ if n > 1 => retry(n - 1)(fn)case Failure(e) => throw e
}}
리팩토링 사례 1 – scala 코드
private def sendMessageToFacebook(loginUser: SocialUser, link: String, receiverId: String, message: String) = {retry(3) {val facebookClient: FacebookClient = createFacebookClient(loginUser)val response: FacebookType = facebookClient.publish(receiverId + "/feed",
classOf[FacebookType], Parameter.`with`("link", link), Parameter.`with`("message", message))
response.getId}
}
public Set<Tag> processGroupTags(Set<FacebookGroup> groupTags) {Set<Tag> tags = Sets.newHashSet();for (FacebookGroup each : groupTags) {
if (each.isEmpty()) {continue;
}
Tag tag = tagRepository.findByGroupId(each.getGroupId());if (tag != null) {
tags.add(tag);continue;
}
tag = tagRepository.findByName(each.getName());if (tag != null) {
tag.moveGroupTag(each.getGroupId());tags.add(tag);continue;
}
Tag newTag = Tag.groupedTag(each.getName(), each.getGroupId());tags.add(tagRepository.save(newTag));
}return tags;
}
리팩토링 사례 2 – java 코드
Option(tagRepository.findByGroupId(facebookGroup.getGroupId)).orElse(Option(tagRepository.findByName(facebookGroup.getName)).map(t => t.moveGroupTag(facebookGroup.getGroupId)))
.getOrElse(tagRepository.save(Tag.groupedTag(facebookGroup.getName, facebookGroup.getGroupId)))
리팩토링 사례 2 – scala 코드
Java에서 Scala 전환 단계
• 1단계 : Java와 Scala를 같이 실행할 수 있는 환경을 구축한다.
• 2단계 : Scala 전환시 위험요소가 있다고 판단되는 부분이 있다면
이에 대한 실험을 먼저 진행하고 전략을 세운다.
• 3단계 : 앞에서 세운 전략에 따라 Scala 전환 작업을 진행한다.
• 4단계 : Scala 스타일로 리팩토링한다.
1. Domain과 DTO의 명확한 분리에 대한 거부감이
줄어듦
• 현재 개발 추세는 Domain 객체와 DTO에 중복되는 부분이 많아 자바
객체 하나가 Domain 역할, DTO 역할을 하는 방식으로 구현.
• Scala를 활용하면 각 역할별로 구현하는 것에 대한 거부감이 줄어듦
@Entityclass User(pEmail: String, pNickName: String, pPassword: String) extends DomainModel {@Id@GeneratedValuevar id: Long = _
@Column(unique = true, nullable = false)val email = pEmail
@Column(name = "nick_name", nullable = false)val nickName = pNickName
@Column(nullable = false)val password = pPassword
def isGuest(): Boolean = {false
}}
User Entity
• 반드시 setter/getter를 생성하지 않아도 된다.
@JsonIgnoreProperties(ignoreUnknown = true)@JsonPropertyOrder(alphabetic = true)@JsonInclude(Include.NON_NULL)@JsonAutoDetect(fieldVisibility = Visibility.ANY, getterVisibility = Visibility.NONE, setterVisibility = Visibility.NONE)trait View
case class UserView(id: Long, email: String, nickName: String) extends View {def this(u: User) = this(u.id, u.email, u.nickName)
def this() = this(new User())}
User View DTO
• Scala case class를 활용하면 자동으로 field 추가함.
class UserForm {@BeanProperty@Emailvar email: String = _
@BeanProperty@NotNull@Size(min = 3, max = 10)var nickName: String = _
@BeanProperty@NotNull@Size(min = 8, max = 15)var password: String = _
def toUser() = new User(email, nickName, password)}
User Form DTO
• @BeanProperty 활용하면 setter/getter method 자동
추가
Domain과 DTO의 명확한 분리에 대한 거부감이
줄어듦
• 분리하는 것이 항상 좋은 것은 아니다.
• 상황에 따라 Domain과 DTO를 분리/통합할 것인지에 대한
역량을 키우는 것이 더 중요하다.
2. Test Fixture(Test Data) 생성하기 용이함.
• 자바에서 Test Fixture를 생성하고 변경하기 어려움은 Test 코드를
만드는데 약간의 장애물이다.
• Scala는 named parameter를 통해 해결 가능
public class UserBuilder {private String email;private String nickname;private String password;
public UserBuilder withEmail(String email) {this.email = email;return this;
}
public UserBuilder withNickname(String nickname) {this.nickname = nickname;return this;
}
public UserBuilder withPassword(String password) {this.password = password;return this;
}
public User build() {return new User(email, nickname, password);
}}
public class UserTest {@Testpublic void canCreate() throws Exception {
User user1 = new UserBuilder().withEmail("[email protected]").build();User user2 = new
UserBuilder().withEmail("[email protected]").withNickname("newname").build();}
}
22장. 복잡한 테스트 데이터 만들기 참고
trait Fixture {def aSomeUser(email: String = "[email protected]", nickname: String = "nickName", password:
String = "password")= new User(email, nickname, password)
}
val user1 = aSomeUserval user2 = aSomeUser(email="[email protected]")val user1 = aSomeUser(nickName="newname")
• Lambda(람다)
• 막강 Collection
• 모나드 등등 …
• Functional Programming
• Case Class
• Named Parameter
• Implicit
• Some/Option
Scala + Spring + JPA 조합에서 불편한 부분
• Java 기반 프레임워크 사용하면서 고려할 부분이 생긴다.
• Scala의 모든 기능을 극한으로 사용하는데 제약 사항이 있다.
@RestControllerclass UserController @Autowired() (val userRepository: UserRepository) {val Log = LoggerFactory.getLogger(classOf[UserController])
@RequestMapping(value = Array("/users"), method = Array(RequestMethod.POST) )def join(@Valid @RequestBody userForm: UserForm, result: BindingResult) = {...
}
@RequestMapping(value = Array("/users/{userEmail}"), method = Array(RequestMethod.POST) )def login(@PathVariable userEmail: String, @RequestParam(required = true)
password:String) = {...
}}
import java.lang.Long
import org.springframework.data.repository.CrudRepository
trait UserRepository extends CrudRepository[User, Long] {def findByEmail(email: String): Userdef findByEmailAndPassword(email: String, password: String): Userdef findByNickName(nickName: String): User
}
import java.util.{ArrayList, List}
@Entityclass Clan(pName: String) extends DomainModel {...
@OneToMany(mappedBy = "clan", cascade = Array(CascadeType.PERSIST, CascadeType.REMOVE), fetch = FetchType.LAZY)val clanMembers: List[ClanMember] = new ArrayList[ClanMember]
}